| 1 | import { createHash } from "node:crypto"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { Unowned } from "../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../Diff.ts"; |
| 9 | import type { PropsInput } from "../Input.ts"; |
| 10 | import * as Provider from "../Provider.ts"; |
| 11 | import { Resource, type ResourceClass } from "../Resource.ts"; |
| 12 | import { createInternalTags } from "../Tags.ts"; |
| 13 | import { bucketStorageClient, type BucketAttributes } from "./Bucket.ts"; |
| 14 | import type { Providers } from "./Providers.ts"; |
| 15 | import type { StoragePutOptions } from "./Storage.ts"; |
| 16 | |
| 17 | export 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 | |
| 57 | export 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 | |
| 82 | export interface Object<T = never> extends Resource< |
| 83 | "Neon.Object", |
| 84 | ObjectProps<T>, |
| 85 | ObjectAttributes, |
| 86 | never, |
| 87 | Providers |
| 88 | > {} |
| 89 | const ObjectResource = Resource<Object<unknown>>("Neon.Object"); |
| 90 | type 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 | */ |
| 125 | export const Object = ObjectResource as ObjectConstructor; |
| 126 | |
| 127 | export class ObjectDecodeError extends Data.TaggedError("ObjectDecodeError")<{ |
| 128 | message: string; |
| 129 | }> {} |
| 130 | |
| 131 | /** Stable JSON with explicit rejection of lossy values and cycles. */ |
| 132 | export 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 | |
| 211 | export 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 | |
| 230 | const hashBytes = (bytes: Uint8Array) => |
| 231 | Effect.sync(() => createHash("sha256").update(bytes).digest("hex")); |
| 232 | const isJson = (props: ObjectProps<unknown>) => "value" in props; |
| 233 | const 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 | |
| 256 | const 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 | |
| 284 | const 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 | |
| 294 | export 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 | }); |