1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import { Bucket, bucketStorageClient } from "@/Neon/Bucket";
3import {
4 Object as NeonObject,
5 serializeObjectValue,
6 storageBodyBytes,
7} from "@/Neon/Object";
8import { Project } from "@/Neon/Project";
9import { providers } from "@/Neon/Providers";
10import * as Test from "@/Test/Alchemy";
11import * as SDK from "@distilled.cloud/neon";
12import { expect } from "alchemy-test";
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(
68 Result.isFailure(
69 yield* serializeObjectValue(value).pipe(Effect.result),
70 ),
71 ).toBe(true);
72 }
73 expect(accessed).toBe(false);
74 }),
75 { tags: ["provider:neon", "provider:neon:object", "live"] },
76);
77
78test.provider(
79 "typed JSON updates metadata file fidelity movement and deletion",
80 (stack) =>
81 Effect.gen(function* () {
82 yield* stack.destroy();
83 const fs = yield* FileSystem.FileSystem;
84 const path = yield* Path.Path;
85 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "neon-object-" });
86 const source = path.join(dir, "object.bin");
87 const bytes = new Uint8Array([0, 255, 1, 127, 32]);
88 yield* fs.writeFile(source, bytes);
89 const deploy = (updated: boolean) =>
90 stack.deploy(
91 Effect.gen(function* () {
92 const project = yield* Project("ObjectProject", {
93 region: "aws-us-east-2",
94 });
95 const bucket = yield* Bucket("Objects", { project });
96 const object = yield* NeonObject("Settings", {
97 bucket,
98 key: updated ? "new/settings.json" : "settings.json",
99 value: {
100 theme: updated ? "dark" : "system",
101 pageSize: updated ? 50 : 25,
102 },
103 cacheControl: updated ? "no-store" : "max-age=60",
104 metadata: updated ? {} : { Phase: "initial" },
105 });
106 const raw = yield* NeonObject("Bytes", {
107 bucket,
108 key: "raw.bin",
109 source,
110 });
111 return { bucket, object, raw };
112 }),
113 );
114 const first = yield* deploy(false);
115 const client = yield* bucketStorageClient(first.bucket);
116 const stored = yield* client.get(first.object.key);
117 expect(stored?.ContentType).toBe("application/json");
118 expect(stored?.Metadata?.phase).toBe("initial");
119 const json = yield* storageBodyBytes(stored?.Body);
120 expect(yield* Effect.sync(() => new TextDecoder().decode(json))).toBe(
121 '{"pageSize":25,"theme":"system"}',
122 );
123 expect(
124 Array.from(
125 yield* storageBodyBytes((yield* client.get("raw.bin"))?.Body),
126 ),
127 ).toEqual(Array.from(bytes));
128 const unchanged = yield* deploy(false);
129 expect(unchanged.object.etag).toBe(first.object.etag);
130 const updated = yield* deploy(true);
131 expect(updated.object.key).toBe("new/settings.json");
132 expect(yield* client.get("settings.json")).toBeUndefined();
133 expect((yield* client.head(updated.object.key))?.CacheControl).toBe(
134 "no-store",
135 );
136 const listing = yield* SDK.listProjectBranchBucketObjects({
137 project_id: updated.bucket.projectId,
138 branch_id: updated.bucket.branchId,
139 bucket_name: updated.bucket.bucketName,
140 });
141 expect(listing).toBeDefined();
142 yield* stack.destroy();
143 yield* stack.destroy();
144 }),
145 {
146 tags: [
147 "provider:neon",
148 "provider:neon:bucket",
149 "provider:neon:object",
150 "provider:neon:project",
151 "live",
152 ],
153 timeout: 120_000,
154 },
155);
156
157test.provider(
158 "foreign object requires adoption and adopted content converges",
159 (stack) =>
160 Effect.gen(function* () {
161 yield* stack.destroy();
162 const bucketProgram = Effect.gen(function* () {
163 const project = yield* Project("ObjectAdoptionProject", {
164 region: "aws-us-east-2",
165 });
166 return yield* Bucket("AdoptionBucket", { project, forceDestroy: true });
167 });
168 const bucket = yield* stack.deploy(bucketProgram);
169 const client = yield* bucketStorageClient(bucket);
170 yield* client.put("foreign.json", '{"count":0}', {
171 ContentType: "application/json",
172 Metadata: { foreign: "yes" },
173 });
174 const program = (takeover: boolean, version = 1) =>
175 Effect.gen(function* () {
176 const bucket = yield* bucketProgram;
177 return yield* NeonObject("AdoptedObject", {
178 bucket,
179 key: "foreign.json",
180 value: { count: 1 },
181 metadata: { version: String(version) },
182 }).pipe(adopt(takeover));
183 });
184 const refused = yield* stack.deploy(program(false)).pipe(Effect.result);
185 expect(Result.isFailure(refused)).toBe(true);
186 if (Result.isFailure(refused))
187 expect(refused.failure).toBeInstanceOf(OwnedBySomeoneElse);
188 expect((yield* client.head("foreign.json"))?.Metadata?.foreign).toBe(
189 "yes",
190 );
191 const adopted = yield* stack.deploy(program(true));
192 expect(adopted.metadata["alchemy-id"]).toBe("AdoptedObject");
193 yield* client.put("foreign.json", '{"count":999}', {
194 Metadata: adopted.metadata,
195 });
196 const repaired = yield* stack.deploy(program(true, 2));
197 expect(repaired.contentHash).toBe(adopted.contentHash);
198 const bytes = yield* storageBodyBytes(
199 (yield* client.get("foreign.json"))?.Body,
200 );
201 expect(yield* Effect.sync(() => new TextDecoder().decode(bytes))).toBe(
202 '{"count":1}',
203 );
204 yield* client.delete("foreign.json");
205 const recreated = yield* stack.deploy(program(true, 3));
206 expect(recreated.contentHash).toBe(adopted.contentHash);
207 yield* stack.destroy();
208 yield* stack.destroy();
209 }),
210 {
211 tags: [
212 "provider:neon",
213 "provider:neon:bucket",
214 "provider:neon:object",
215 "provider:neon:project",
216 "live",
217 ],
218 timeout: 120_000,
219 },
220);