Open History to see the last commit
1import { Bucket, bucketStorageClient } from "@alchemy.run/neon/Bucket";
2import {
3 Object as NeonObject,
4 serializeObjectValue,
5 storageBodyBytes,
6} from "@alchemy.run/neon/Object";
7import { Project } from "@alchemy.run/neon/Project";
8import { providers } from "@alchemy.run/neon/Providers";
9import { expect } from "@alchemy.run/test";
10import * as Test from "@alchemy.run/test/Alchemy";
11import * as SDK from "@distilled.cloud/neon";
12import { adopt, OwnedBySomeoneElse } from "alchemy/AdoptPolicy";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Path from "effect/Path";
16import * as Result from "effect/Result";
17
18const { test } = Test.make({ providers: providers() });
19
20test(
21 "JSON serialization is stable and rejects lossy values",
22 Effect.gen(function* () {
23 expect(
24 yield* serializeObjectValue({
25 z: [true, null, "str"],
26 a: { y: 2, x: 1 },
27 }),
28 ).toBe('{"a":{"x":1,"y":2},"z":[true,null,"str"]}');
29 const cycle: Record<string, unknown> = {};
30 cycle.self = cycle;
31 const hiddenRecord = yield* Effect.sync(() =>
32 Object.defineProperty({}, "hidden", { value: 1 }),
33 );
34 const hiddenArray = yield* Effect.sync(() =>
35 Object.defineProperty([1], "hidden", { value: 2 }),
36 );
37 let accessed = false;
38 const accessor = {
39 get value() {
40 accessed = true;
41 return 1;
42 },
43 };
44 const accessorArray = yield* Effect.sync(() =>
45 Object.defineProperty([1], "0", {
46 get() {
47 accessed = true;
48 return 1;
49 },
50 }),
51 );
52 for (const value of [
53 hiddenRecord,
54 hiddenArray,
55 accessor,
56 accessorArray,
57 undefined,
58 NaN,
59 Infinity,
60 1n,
61 { lost: undefined },
62 [, 1],
63 cycle,
64 new Date(0),
65 { [Symbol("key")]: 1 },
66 ]) {
67 expect(Result.isFailure(yield* serializeObjectValue(value).pipe(Effect.result))).toBe(true);
68 }
69 expect(accessed).toBe(false);
70 }),
71 { tags: ["provider:neon", "provider:neon:object", "live"] },
72);
73
74test.provider(
75 "typed JSON updates metadata file fidelity movement and deletion",
76 (stack) =>
77 Effect.gen(function* () {
78 yield* stack.destroy();
79 const fs = yield* FileSystem.FileSystem;
80 const path = yield* Path.Path;
81 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "neon-object-" });
82 const source = path.join(dir, "object.bin");
83 const bytes = new Uint8Array([0, 255, 1, 127, 32]);
84 yield* fs.writeFile(source, bytes);
85 const deploy = (updated: boolean) =>
86 stack.deploy(
87 Effect.gen(function* () {
88 const project = yield* Project("ObjectProject", {
89 region: "aws-us-east-2",
90 });
91 const bucket = yield* Bucket("Objects", { project });
92 const object = yield* NeonObject("Settings", {
93 bucket,
94 key: updated ? "new/settings.json" : "settings.json",
95 value: {
96 theme: updated ? "dark" : "system",
97 pageSize: updated ? 50 : 25,
98 },
99 cacheControl: updated ? "no-store" : "max-age=60",
100 metadata: updated ? {} : { Phase: "initial" },
101 });
102 const raw = yield* NeonObject("Bytes", {
103 bucket,
104 key: "raw.bin",
105 source,
106 });
107 return { bucket, object, raw };
108 }),
109 );
110 const first = yield* deploy(false);
111 const client = yield* bucketStorageClient(first.bucket);
112 const stored = yield* client.get(first.object.key);
113 expect(stored?.ContentType).toBe("application/json");
114 expect(stored?.Metadata?.phase).toBe("initial");
115 const json = yield* storageBodyBytes(stored?.Body);
116 expect(yield* Effect.sync(() => new TextDecoder().decode(json))).toBe(
117 '{"pageSize":25,"theme":"system"}',
118 );
119 expect(Array.from(yield* storageBodyBytes((yield* client.get("raw.bin"))?.Body))).toEqual(
120 Array.from(bytes),
121 );
122 const unchanged = yield* deploy(false);
123 expect(unchanged.object.etag).toBe(first.object.etag);
124 const updated = yield* deploy(true);
125 expect(updated.object.key).toBe("new/settings.json");
126 expect(yield* client.get("settings.json")).toBeUndefined();
127 expect((yield* client.head(updated.object.key))?.CacheControl).toBe("no-store");
128 const listing = yield* SDK.listProjectBranchBucketObjects({
129 project_id: updated.bucket.projectId,
130 branch_id: updated.bucket.branchId,
131 bucket_name: updated.bucket.bucketName,
132 });
133 expect(listing).toBeDefined();
134 yield* stack.destroy();
135 yield* stack.destroy();
136 }),
137 {
138 tags: [
139 "provider:neon",
140 "provider:neon:bucket",
141 "provider:neon:object",
142 "provider:neon:project",
143 "live",
144 ],
145 timeout: 120_000,
146 },
147);
148
149test.provider(
150 "foreign object requires adoption and adopted content converges",
151 (stack) =>
152 Effect.gen(function* () {
153 yield* stack.destroy();
154 const bucketProgram = Effect.gen(function* () {
155 const project = yield* Project("ObjectAdoptionProject", {
156 region: "aws-us-east-2",
157 });
158 return yield* Bucket("AdoptionBucket", { project, forceDestroy: true });
159 });
160 const bucket = yield* stack.deploy(bucketProgram);
161 const client = yield* bucketStorageClient(bucket);
162 yield* client.put("foreign.json", '{"count":0}', {
163 ContentType: "application/json",
164 Metadata: { foreign: "yes" },
165 });
166 const program = (takeover: boolean, version = 1) =>
167 Effect.gen(function* () {
168 const bucket = yield* bucketProgram;
169 return yield* NeonObject("AdoptedObject", {
170 bucket,
171 key: "foreign.json",
172 value: { count: 1 },
173 metadata: { version: String(version) },
174 }).pipe(adopt(takeover));
175 });
176 const refused = yield* stack.deploy(program(false)).pipe(Effect.result);
177 expect(Result.isFailure(refused)).toBe(true);
178 if (Result.isFailure(refused)) expect(refused.failure).toBeInstanceOf(OwnedBySomeoneElse);
179 expect((yield* client.head("foreign.json"))?.Metadata?.foreign).toBe("yes");
180 const adopted = yield* stack.deploy(program(true));
181 expect(adopted.metadata["alchemy-id"]).toBe("AdoptedObject");
182 yield* client.put("foreign.json", '{"count":999}', {
183 Metadata: adopted.metadata,
184 });
185 const repaired = yield* stack.deploy(program(true, 2));
186 expect(repaired.contentHash).toBe(adopted.contentHash);
187 const bytes = yield* storageBodyBytes((yield* client.get("foreign.json"))?.Body);
188 expect(yield* Effect.sync(() => new TextDecoder().decode(bytes))).toBe('{"count":1}');
189 yield* client.delete("foreign.json");
190 const recreated = yield* stack.deploy(program(true, 3));
191 expect(recreated.contentHash).toBe(adopted.contentHash);
192 yield* stack.destroy();
193 yield* stack.destroy();
194 }),
195 {
196 tags: [
197 "provider:neon",
198 "provider:neon:bucket",
199 "provider:neon:object",
200 "provider:neon:project",
201 "live",
202 ],
203 timeout: 120_000,
204 },
205);