1import { createHash } from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { CurrentRuntimeContext } from "../RuntimeContext.ts";
5import { isResource } from "../Resource.ts";
6import type { Bucket } from "./Bucket.ts";
7import { bindBackendEnvironment } from "./BackendConnection.ts";
8import {
9 ObjectDecodeError,
10 serializeObjectValue,
11 storageBodyBytes,
12 type Object,
13} from "./Object.ts";
14import type { ReadObjectClient, ObjectValue } from "./ReadObject.ts";
15import type { WriteObjectClient } from "./WriteObject.ts";
16import {
17 makeStorageBinding,
18 StorageBindingError,
19 type StorageBindingOptions,
20} from "./StorageBinding.ts";
21
22const isBucket = (value: unknown): value is Bucket =>
23 isResource(value) && value.Type === "Neon.Bucket";
24
25export const makeReadObjectHttp = () =>
26 Effect.gen(function* () {
27 const bind = yield* makeStorageBinding("storage:read");
28 const runtime = yield* CurrentRuntimeContext;
29 if (!runtime)
30 return yield* Effect.die(
31 new StorageBindingError({
32 message: "Object bindings require a Platform host",
33 }),
34 );
35 return Effect.fn(function* <T>(
36 object: Object<T>,
37 options?: StorageBindingOptions,
38 ) {
39 const target = object.Props.bucket;
40 if (!isBucket(target))
41 return yield* Effect.die(
42 new StorageBindingError({
43 message: "Object bindings require a Neon.Bucket resource reference",
44 }),
45 );
46 const bucket = yield* bind(target, options);
47 const name = yield* Effect.sync(
48 () =>
49 `NEON_OBJECT_${createHash("sha256").update(object.FQN).digest("hex").slice(0, 24).toUpperCase()}_KEY`,
50 );
51 yield* bindBackendEnvironment(`${object.FQN}:ReadObject`, {
52 [name]: object.key,
53 });
54 const key = runtime.get<string>(name).pipe(
55 Effect.flatMap((key) =>
56 key === undefined
57 ? Effect.fail(
58 new StorageBindingError({
59 message: "Object key was not bound",
60 }),
61 )
62 : Effect.succeed(key),
63 ),
64 );
65 const bytes = Effect.gen(function* () {
66 const object = yield* bucket.get(yield* key);
67 return object ? yield* storageBodyBytes(object.Body) : undefined;
68 });
69 const get = Effect.gen(function* () {
70 const body = yield* bytes;
71 if (body === undefined) return undefined;
72 if (!("value" in object.Props)) return body as ObjectValue<T>;
73 const parsed: unknown = yield* Effect.try({
74 try: () => JSON.parse(new TextDecoder().decode(body)),
75 catch: () =>
76 new ObjectDecodeError({
77 message: "Stored object is not valid JSON",
78 }),
79 });
80 if (!object.Props.schema) return parsed as ObjectValue<T>;
81 return (yield* Schema.decodeUnknownEffect(object.Props.schema)(
82 parsed,
83 ).pipe(
84 Effect.mapError(
85 () =>
86 new ObjectDecodeError({
87 message: "Stored JSON does not satisfy the object's schema",
88 }),
89 ),
90 )) as ObjectValue<T>;
91 });
92 return {
93 get: () => get,
94 bytes: () => bytes,
95 } satisfies ReadObjectClient<T>;
96 });
97 });
98
99export const makeWriteObjectHttp = () =>
100 Effect.gen(function* () {
101 const bind = yield* makeStorageBinding("storage:write");
102 const runtime = yield* CurrentRuntimeContext;
103 if (!runtime)
104 return yield* Effect.die(
105 new StorageBindingError({
106 message: "Object bindings require a Platform host",
107 }),
108 );
109 return Effect.fn(function* <T>(
110 object: Object<T>,
111 options?: StorageBindingOptions,
112 ) {
113 const target = object.Props.bucket;
114 if (!isBucket(target))
115 return yield* Effect.die(
116 new StorageBindingError({
117 message: "Object bindings require a Neon.Bucket resource reference",
118 }),
119 );
120 const bucket = yield* bind(target, options);
121 const name = yield* Effect.sync(
122 () =>
123 `NEON_OBJECT_${createHash("sha256").update(object.FQN).digest("hex").slice(0, 24).toUpperCase()}_KEY`,
124 );
125 yield* bindBackendEnvironment(`${object.FQN}:WriteObject`, {
126 [name]: object.key,
127 });
128 const put = Effect.fn(function* (
129 value: [T] extends [never] ? string | Uint8Array : T,
130 ) {
131 const key = yield* runtime.get<string>(name);
132 if (key === undefined)
133 return yield* new StorageBindingError({
134 message: "Object key was not bound",
135 });
136 const observed = yield* bucket.head(key);
137 const metadata = {
138 ContentType: observed?.ContentType,
139 CacheControl: observed?.CacheControl,
140 ContentDisposition: observed?.ContentDisposition,
141 ContentEncoding: observed?.ContentEncoding,
142 Metadata: observed?.Metadata,
143 };
144 if (!("value" in object.Props)) {
145 return yield* bucket.put(key, value as string | Uint8Array, metadata);
146 }
147 const checked = object.Props.schema
148 ? yield* Schema.decodeUnknownEffect(object.Props.schema)(value).pipe(
149 Effect.mapError(
150 () =>
151 new ObjectDecodeError({
152 message:
153 "Written JSON does not satisfy the object's schema",
154 }),
155 ),
156 )
157 : value;
158 return yield* bucket.put(key, yield* serializeObjectValue(checked), {
159 ...metadata,
160 ContentType: metadata.ContentType ?? "application/json",
161 });
162 });
163 return { put } satisfies WriteObjectClient<T>;
164 });
165 });