1import { createHash } from "node:crypto";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Schema from "effect/Schema";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../AdoptPolicy.ts";
8import { isResolved } from "../Diff.ts";
9import type { PropsInput } from "../Input.ts";
10import * as Provider from "../Provider.ts";
11import { Resource, type ResourceClass } from "../Resource.ts";
12import { createInternalTags } from "../Tags.ts";
13import { bucketStorageClient, type BucketAttributes } from "./Bucket.ts";
14import type { Providers } from "./Providers.ts";
15import type { StoragePutOptions } from "./Storage.ts";
16
17export type ObjectProps<T = never> = {
18 /** Target bucket; its branch and credentials are inferred. */
19 bucket: BucketAttributes;
20 /** Object key. Moving keys or buckets replaces the object create-first. */
21 key: string;
22 /** Content type. JSON values default to application/json. */
23 contentType?: string;
24 /** HTTP cache policy. */
25 cacheControl?: string;
26 /** HTTP download filename/disposition. */
27 contentDisposition?: string;
28 /** HTTP content encoding. */
29 contentEncoding?: string;
30 /** Application metadata; keys are normalized to lowercase and alchemy-* names are reserved. */
31 metadata?: Record<string, string>;
32} & (
33 | {
34 /** Typed JSON desired state, serialized deterministically. */
35 value: T;
36 /** Optional runtime validation of externally written JSON. */
37 schema?: Schema.Codec<T>;
38 body?: never;
39 source?: never;
40 }
41 | {
42 /** Raw text or bytes; not JSON encoded. */
43 body: string | Uint8Array;
44 value?: never;
45 source?: never;
46 schema?: never;
47 }
48 | {
49 /** File read through the Effect filesystem during reconciliation. */
50 source: string;
51 value?: never;
52 body?: never;
53 schema?: never;
54 }
55);
56
57export interface ObjectAttributes {
58 /** Target bucket and branch identity. */
59 bucket: BucketAttributes;
60 /** Object key. */
61 key: string;
62 /** Opaque S3 entity tag; never assumed to be an MD5. */
63 etag: string | undefined;
64 /** Observed byte length. */
65 size: number;
66 /** SHA-256 of observed bytes. */
67 contentHash: string;
68 /** Whether the declared payload is JSON. */
69 json: boolean;
70 /** Observed content type. */
71 contentType: string | undefined;
72 /** Observed cache policy. */
73 cacheControl: string | undefined;
74 /** Observed content disposition. */
75 contentDisposition: string | undefined;
76 /** Observed encoding. */
77 contentEncoding: string | undefined;
78 /** Observed application and ownership metadata. */
79 metadata: Record<string, string>;
80}
81
82export interface Object<T = never> extends Resource<
83 "Neon.Object",
84 ObjectProps<T>,
85 ObjectAttributes,
86 never,
87 Providers
88> {}
89const ObjectResource = Resource<Object<unknown>>("Neon.Object");
90type ObjectConstructor = Pick<
91 ResourceClass<Object<unknown>>,
92 "Type" | "Provider" | "Props" | "Self" | "Aliases"
93> & {
94 <T = never>(
95 id: string,
96 props: PropsInput<ObjectProps<T>>,
97 ): Effect.Effect<Object<T>, never, Providers>;
98};
99
100/**
101 * A typed declarative object. JSON values are serialized automatically and their
102 * type is retained by ReadObject and WriteObject. A generic alone is not runtime
103 * validation; add a schema when external writers may violate the contract.
104 * Declared content is infrastructure desired state, not mutable application data.
105 *
106 * ### JSON Values
107 * **Example:** Inferred settings
108 * ```typescript
109 * const settings = yield* Neon.Object("Settings", {
110 * bucket: uploads, key: "settings.json", value: { theme: "system", pageSize: 25 },
111 * });
112 * ```
113 *
114 * ### File Content
115 * **Example:** An SVG without JSON serialization
116 * ```typescript
117 * const logo = yield* Neon.Object("Logo", {
118 * bucket: assets, key: "logo.svg", source: "./assets/logo.svg", contentType: "image/svg+xml",
119 * });
120 * ```
121 *
122 * @resource
123 * @product Bucket
124 */
125export const Object = ObjectResource as ObjectConstructor;
126
127export class ObjectDecodeError extends Data.TaggedError("ObjectDecodeError")<{
128 message: string;
129}> {}
130
131/** Stable JSON with explicit rejection of lossy values and cycles. */
132export const serializeObjectValue = (value: unknown) =>
133 Effect.try({
134 try: () => {
135 const ancestors = new Set<object>();
136 const encode = (value: unknown): string => {
137 if (
138 value === null ||
139 typeof value === "string" ||
140 typeof value === "boolean"
141 )
142 return JSON.stringify(value);
143 if (typeof value === "number" && Number.isFinite(value))
144 return JSON.stringify(value);
145 if (typeof value !== "object" || value === null)
146 throw new Error("Unsupported JSON value");
147 if (ancestors.has(value)) throw new Error("Cyclic JSON value");
148 if (Reflect.ownKeys(value).some((key) => typeof key === "symbol"))
149 throw new Error("Symbol JSON keys are unsupported");
150 ancestors.add(value);
151 let encoded: string;
152 if (Array.isArray(value)) {
153 if (
154 globalThis.Object.getOwnPropertyNames(value).length !==
155 value.length + 1
156 )
157 throw new Error(
158 "Sparse arrays and array properties are unsupported",
159 );
160 encoded = `[${Array.from({ length: value.length }, (_, index) => {
161 const descriptor = globalThis.Object.getOwnPropertyDescriptor(
162 value,
163 String(index),
164 );
165 if (
166 !descriptor ||
167 !descriptor.enumerable ||
168 !("value" in descriptor)
169 )
170 throw new Error(
171 "Sparse arrays, non-enumerable fields and accessors are unsupported",
172 );
173 return encode(descriptor.value);
174 }).join(",")}]`;
175 } else {
176 if (
177 globalThis.Object.getPrototypeOf(value) !==
178 globalThis.Object.prototype &&
179 globalThis.Object.getPrototypeOf(value) !== null
180 )
181 throw new Error("JSON values must be plain records");
182 if (
183 globalThis.Object.getOwnPropertyNames(value).length !==
184 globalThis.Object.keys(value).length
185 )
186 throw new Error("Non-enumerable JSON fields are unsupported");
187 encoded = `{${globalThis.Object.keys(value)
188 .sort()
189 .map((key) => {
190 const descriptor = globalThis.Object.getOwnPropertyDescriptor(
191 value,
192 key,
193 )!;
194 if (!("value" in descriptor))
195 throw new Error("JSON accessors are unsupported");
196 return `${JSON.stringify(key)}:${encode(descriptor.value)}`;
197 })
198 .join(",")}}`;
199 }
200 ancestors.delete(value);
201 return encoded;
202 };
203 return encode(value);
204 },
205 catch: () =>
206 new ObjectDecodeError({
207 message: "Value is not losslessly serializable JSON",
208 }),
209 });
210
211export const storageBodyBytes = (
212 body: Stream.Stream<Uint8Array, Error> | undefined,
213) =>
214 body === undefined
215 ? Effect.succeed(new Uint8Array())
216 : Stream.runCollect(body).pipe(
217 Effect.map((chunks) => {
218 const bytes = new Uint8Array(
219 chunks.reduce((size, chunk) => size + chunk.length, 0),
220 );
221 let offset = 0;
222 for (const chunk of chunks) {
223 bytes.set(chunk, offset);
224 offset += chunk.length;
225 }
226 return bytes;
227 }),
228 );
229
230const hashBytes = (bytes: Uint8Array) =>
231 Effect.sync(() => createHash("sha256").update(bytes).digest("hex"));
232const isJson = (props: ObjectProps<unknown>) => "value" in props;
233const desiredBytes = Effect.fn(function* (props: ObjectProps<unknown>) {
234 if (isJson(props)) {
235 const value = props.schema
236 ? yield* Schema.decodeUnknownEffect(props.schema)(props.value).pipe(
237 Effect.mapError(
238 () =>
239 new ObjectDecodeError({
240 message: "Declared JSON does not satisfy its schema",
241 }),
242 ),
243 )
244 : props.value;
245 const json = yield* serializeObjectValue(value);
246 return yield* Effect.sync(() => new TextEncoder().encode(json));
247 }
248 if (props.source !== undefined)
249 return yield* (yield* FileSystem.FileSystem).readFile(props.source);
250 const body = props.body!;
251 return typeof body === "string"
252 ? yield* Effect.sync(() => new TextEncoder().encode(body))
253 : body;
254});
255
256const observeObject = Effect.fn(function* (
257 bucket: BucketAttributes,
258 key: string,
259 json: boolean,
260) {
261 const client = yield* bucketStorageClient(bucket);
262 const object = yield* client.get(key);
263 if (!object) return undefined;
264 const body = yield* storageBodyBytes(object.Body);
265 return {
266 bucket,
267 key,
268 json,
269 etag: object.ETag,
270 size: body.length,
271 contentHash: yield* hashBytes(body),
272 contentType: object.ContentType,
273 cacheControl: object.CacheControl,
274 contentDisposition: object.ContentDisposition,
275 contentEncoding: object.ContentEncoding,
276 metadata: globalThis.Object.fromEntries(
277 globalThis.Object.entries(object.Metadata ?? {}).filter(
278 (entry): entry is [string, string] => entry[1] !== undefined,
279 ),
280 ),
281 } satisfies ObjectAttributes;
282});
283
284const ownership = Effect.fn(function* (id: string, bucket: BucketAttributes) {
285 const tags = yield* createInternalTags(id);
286 return {
287 "alchemy-stack": tags["alchemy::stack"],
288 "alchemy-stage": tags["alchemy::stage"],
289 "alchemy-id": id,
290 "alchemy-branch": bucket.branchId,
291 };
292});
293
294export const ObjectProvider = () =>
295 Provider.succeed(ObjectResource, {
296 stables: ["key"],
297 diff: Effect.fn(function* ({ news, output }) {
298 if (!isResolved(news) || !output) return;
299 if (
300 news.key !== output.key ||
301 news.bucket.bucketName !== output.bucket.bucketName ||
302 news.bucket.projectId !== output.bucket.projectId ||
303 news.bucket.branchId !== output.bucket.branchId
304 )
305 return { action: "replace" };
306 if (news.source !== undefined) return { action: "update" };
307 }),
308 read: Effect.fn(function* ({ fqn, olds, output }) {
309 const bucket = output?.bucket ?? olds?.bucket;
310 if (!bucket?.bucketName) return undefined;
311 const attrs = yield* observeObject(
312 bucket,
313 output?.key ?? olds.key,
314 isJson(olds),
315 );
316 if (!attrs) return undefined;
317 const expected = yield* ownership(fqn, attrs.bucket);
318 return globalThis.Object.entries(expected).every(
319 ([key, value]) => attrs.metadata[key] === value,
320 )
321 ? attrs
322 : Unowned(attrs);
323 }),
324 reconcile: Effect.fn(function* ({ fqn, news }) {
325 const bucket = news.bucket;
326 const client = yield* bucketStorageClient(bucket);
327 const body = yield* desiredBytes(news);
328 const hash = yield* hashBytes(body);
329 const current = yield* observeObject(bucket, news.key, isJson(news));
330 if (
331 globalThis.Object.keys(news.metadata ?? {}).some((key) =>
332 key.toLowerCase().startsWith("alchemy-"),
333 )
334 ) {
335 return yield* new ObjectDecodeError({
336 message: "alchemy-* object metadata is reserved for ownership",
337 });
338 }
339 const entries = globalThis.Object.entries(news.metadata ?? {});
340 const applicationMetadata = globalThis.Object.fromEntries(
341 entries.map(([key, value]) => [key.toLowerCase(), value]),
342 );
343 if (
344 globalThis.Object.keys(applicationMetadata).length !== entries.length
345 ) {
346 return yield* new ObjectDecodeError({
347 message: "Object metadata keys must be unique ignoring case",
348 });
349 }
350 const metadata = {
351 ...applicationMetadata,
352 ...(yield* ownership(fqn, bucket)),
353 };
354 const options: StoragePutOptions = {
355 ContentType:
356 news.contentType ??
357 (isJson(news) ? "application/json" : "application/octet-stream"),
358 CacheControl: news.cacheControl,
359 ContentDisposition: news.contentDisposition,
360 ContentEncoding: news.contentEncoding,
361 Metadata: metadata,
362 };
363 if (
364 !current ||
365 current.contentHash !== hash ||
366 current.contentType !== options.ContentType ||
367 current.cacheControl !== options.CacheControl ||
368 current.contentDisposition !== options.ContentDisposition ||
369 current.contentEncoding !== options.ContentEncoding ||
370 (yield* serializeObjectValue(current.metadata)) !==
371 (yield* serializeObjectValue(metadata))
372 ) {
373 yield* client.put(news.key, body, options);
374 }
375 const result = yield* observeObject(bucket, news.key, isJson(news));
376 if (!result)
377 return yield* new ObjectDecodeError({
378 message: "Object was not readable after upload",
379 });
380 return result;
381 }),
382 delete: Effect.fn(function* ({ output }) {
383 const client = yield* bucketStorageClient(output.bucket);
384 yield* client
385 .delete(output.key)
386 .pipe(Effect.catchTag("NoSuchBucket", () => Effect.void));
387 }),
388 });