@prisma/composer-prisma-cloud 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/control.d.mts +56 -0
- package/dist/control.mjs +1814 -0
- package/dist/control.mjs.map +1 -0
- package/dist/cron/index.d.mts +96 -0
- package/dist/cron/index.mjs +356 -0
- package/dist/cron/index.mjs.map +1 -0
- package/dist/cron/scheduler-entrypoint.mjs +7713 -0
- package/dist/cron/scheduler-entrypoint.mjs.map +1 -0
- package/dist/cron/scheduler-service.mjs +282 -0
- package/dist/cron/scheduler-service.mjs.map +1 -0
- package/dist/index.d.mts +168 -0
- package/dist/index.mjs +179 -0
- package/dist/index.mjs.map +1 -0
- package/dist/prisma-next-COrwlg3N.mjs +176 -0
- package/dist/prisma-next-COrwlg3N.mjs.map +1 -0
- package/dist/prisma-next.d.mts +72 -0
- package/dist/prisma-next.mjs +2 -0
- package/dist/serializer-C2CsA7xm-29Eg2Tjl.mjs +207 -0
- package/dist/serializer-C2CsA7xm-29Eg2Tjl.mjs.map +1 -0
- package/dist/storage/index.d.mts +67 -0
- package/dist/storage/index.mjs +374 -0
- package/dist/storage/index.mjs.map +1 -0
- package/dist/storage/storage-entrypoint.mjs +1138 -0
- package/dist/storage/storage-entrypoint.mjs.map +1 -0
- package/dist/storage/storage-service.mjs +340 -0
- package/dist/storage/storage-service.mjs.map +1 -0
- package/dist/storage/testing.d.mts +82 -0
- package/dist/storage/testing.mjs +531 -0
- package/dist/storage/testing.mjs.map +1 -0
- package/dist/testing.d.mts +26 -0
- package/dist/testing.mjs +32 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
//#region ../../1-prisma-cloud/2-shared-modules/storage/dist/storage-entrypoint.mjs
|
|
4
|
+
/** Connection resilience helpers for Prisma Postgres cold-starts (FT-5226); no heavy imports (no `effect`/`alchemy`/`pg`), so the deploy lowerings, the pnPostgres runtime client, and bun-runnable services (the storage store, via the pure `@internal/prisma-cloud/connection` subpath) all share one implementation. */
|
|
5
|
+
/** Network-level socket failures node-postgres surfaces as `err.code`. */
|
|
6
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
7
|
+
"ECONNREFUSED",
|
|
8
|
+
"ECONNRESET",
|
|
9
|
+
"ETIMEDOUT",
|
|
10
|
+
"EPIPE",
|
|
11
|
+
"ENOTFOUND",
|
|
12
|
+
"EAI_AGAIN"
|
|
13
|
+
]);
|
|
14
|
+
/** Connection-establishment failure messages (no useful `err.code`). */
|
|
15
|
+
const TRANSIENT_MESSAGE_FRAGMENTS = [
|
|
16
|
+
"upstream database",
|
|
17
|
+
"connection terminated",
|
|
18
|
+
"connection refused",
|
|
19
|
+
"terminating connection",
|
|
20
|
+
"server closed the connection",
|
|
21
|
+
"connection timeout",
|
|
22
|
+
"timeout expired"
|
|
23
|
+
];
|
|
24
|
+
/** Whether an error is a transient connection failure worth retrying, as opposed to a real query error that must surface at once. */
|
|
25
|
+
function isTransientConnectionError(error) {
|
|
26
|
+
if (typeof error !== "object" || error === null) return false;
|
|
27
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
28
|
+
if (code !== void 0 && TRANSIENT_CODES.has(code)) return true;
|
|
29
|
+
const message = "message" in error && typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
30
|
+
return TRANSIENT_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Retries an operation past a transient connection failure, bounded (default
|
|
34
|
+
* ~1 min). `shouldRetry` decides what's transient — defaults to retrying
|
|
35
|
+
* everything; the runtime client passes {@link isTransientConnectionError}.
|
|
36
|
+
*/
|
|
37
|
+
async function withConnectionRetry(operation, opts = {}) {
|
|
38
|
+
const attempts = opts.attempts ?? 12;
|
|
39
|
+
const delayMs = opts.delayMs ?? 5e3;
|
|
40
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
41
|
+
const shouldRetry = opts.shouldRetry ?? (() => true);
|
|
42
|
+
let lastError;
|
|
43
|
+
for (let attempt = 1; attempt <= attempts; attempt++) try {
|
|
44
|
+
return await operation();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!shouldRetry(error)) throw error;
|
|
47
|
+
lastError = error;
|
|
48
|
+
if (attempt < attempts) await sleep(delayMs);
|
|
49
|
+
}
|
|
50
|
+
throw lastError;
|
|
51
|
+
}
|
|
52
|
+
/** Retries acquiring a connection past a transient cold-start; {@link withConnectionRetry} with {@link isTransientConnectionError} fixed as the predicate. */
|
|
53
|
+
function retryTransientConnect(acquire, opts = {}) {
|
|
54
|
+
return withConnectionRetry(acquire, {
|
|
55
|
+
...opts,
|
|
56
|
+
shouldRetry: isTransientConnectionError
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `ObjectStore` over Postgres `bytea` (spec § 3): one `objects` table,
|
|
61
|
+
* single-row-per-object. Ranged reads use SQL `substring` so a range request
|
|
62
|
+
* never detoasts the whole object. The schema is applied idempotently at init
|
|
63
|
+
* behind a bounded connection retry — the first connect to a freshly
|
|
64
|
+
* provisioned Postgres is rejected while the upstream is cold (FT-5226).
|
|
65
|
+
*
|
|
66
|
+
* Runtime engine code (`bun` SQL + `node:crypto`); NOT re-exported from the
|
|
67
|
+
* authoring barrel.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_MAX_KEYS$1 = 1e3;
|
|
70
|
+
function etagOf(bytes) {
|
|
71
|
+
return `"${createHash("sha256").update(bytes).digest("hex")}"`;
|
|
72
|
+
}
|
|
73
|
+
/** bytea comes back as a Node Buffer (a Uint8Array). Fail closed on anything else rather than returning wrong bytes. */
|
|
74
|
+
function toBytes(value) {
|
|
75
|
+
if (value instanceof Uint8Array) return value;
|
|
76
|
+
throw new TypeError(`expected bytea to decode as Uint8Array, got ${typeof value}`);
|
|
77
|
+
}
|
|
78
|
+
/** One row → GetResult (both the whole-object and ranged queries alias the payload as `bytes`; `size` is the bigint total). */
|
|
79
|
+
function toGetResult(row) {
|
|
80
|
+
return {
|
|
81
|
+
bytes: toBytes(row.bytes),
|
|
82
|
+
etag: row.etag,
|
|
83
|
+
contentType: row.content_type,
|
|
84
|
+
size: Number(row.size)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var PgObjectStore = class {
|
|
88
|
+
sql;
|
|
89
|
+
constructor(sql) {
|
|
90
|
+
this.sql = sql;
|
|
91
|
+
}
|
|
92
|
+
async put(bucket, key, bytes, opts = {}) {
|
|
93
|
+
const etag = etagOf(bytes);
|
|
94
|
+
const contentType = opts.contentType ?? "application/octet-stream";
|
|
95
|
+
await this.sql`
|
|
96
|
+
insert into objects (bucket, key, bytes, size, etag, content_type)
|
|
97
|
+
values (${bucket}, ${key}, ${bytes}, ${bytes.byteLength}, ${etag}, ${contentType})
|
|
98
|
+
on conflict (bucket, key) do update set
|
|
99
|
+
bytes = excluded.bytes, size = excluded.size,
|
|
100
|
+
etag = excluded.etag, content_type = excluded.content_type`;
|
|
101
|
+
return { etag };
|
|
102
|
+
}
|
|
103
|
+
async get(bucket, key, opts = {}) {
|
|
104
|
+
const range = opts.range;
|
|
105
|
+
if (range) {
|
|
106
|
+
const from = range.start + 1;
|
|
107
|
+
const row = (range.end === void 0 ? await this.sql`select substring(bytes from ${from}) as bytes, size, etag, content_type
|
|
108
|
+
from objects where bucket = ${bucket} and key = ${key}` : await this.sql`select substring(bytes from ${from} for ${range.end - range.start + 1}) as bytes,
|
|
109
|
+
size, etag, content_type
|
|
110
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
111
|
+
return row === void 0 ? null : toGetResult(row);
|
|
112
|
+
}
|
|
113
|
+
const row = (await this.sql`select bytes, size, etag, content_type
|
|
114
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
115
|
+
return row === void 0 ? null : toGetResult(row);
|
|
116
|
+
}
|
|
117
|
+
async head(bucket, key) {
|
|
118
|
+
const row = (await this.sql`select size, etag, content_type
|
|
119
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
120
|
+
if (row === void 0) return null;
|
|
121
|
+
return {
|
|
122
|
+
etag: row.etag,
|
|
123
|
+
size: Number(row.size),
|
|
124
|
+
contentType: row.content_type
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async delete(bucket, key) {
|
|
128
|
+
await this.sql`delete from objects where bucket = ${bucket} and key = ${key}`;
|
|
129
|
+
}
|
|
130
|
+
async list(bucket, opts = {}) {
|
|
131
|
+
const prefix = opts.prefix ?? "";
|
|
132
|
+
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS$1;
|
|
133
|
+
const token = opts.continuationToken;
|
|
134
|
+
const limit = maxKeys + 1;
|
|
135
|
+
const keys = (token === void 0 ? await this.sql`select key from objects
|
|
136
|
+
where bucket = ${bucket} and starts_with(key, ${prefix})
|
|
137
|
+
order by key limit ${limit}` : await this.sql`select key from objects
|
|
138
|
+
where bucket = ${bucket} and starts_with(key, ${prefix}) and key > ${token}
|
|
139
|
+
order by key limit ${limit}`).map((r) => r.key);
|
|
140
|
+
const isTruncated = keys.length > maxKeys;
|
|
141
|
+
const page = isTruncated ? keys.slice(0, maxKeys) : keys;
|
|
142
|
+
const last = page.at(-1);
|
|
143
|
+
return {
|
|
144
|
+
keys: page,
|
|
145
|
+
isTruncated,
|
|
146
|
+
...isTruncated && last !== void 0 ? { nextContinuationToken: last } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Connect (FT-5219 posture: `max: 1`, short `idleTimeout`), apply the schema
|
|
152
|
+
* idempotently behind the cold-start retry, and return the store.
|
|
153
|
+
*/
|
|
154
|
+
async function createPgStore(url) {
|
|
155
|
+
const sql = new SQL({
|
|
156
|
+
url,
|
|
157
|
+
max: 1,
|
|
158
|
+
idleTimeout: 10
|
|
159
|
+
});
|
|
160
|
+
await retryTransientConnect(() => sql`
|
|
161
|
+
create table if not exists objects (
|
|
162
|
+
bucket text not null,
|
|
163
|
+
key text not null,
|
|
164
|
+
bytes bytea not null,
|
|
165
|
+
size bigint not null,
|
|
166
|
+
etag text not null,
|
|
167
|
+
content_type text not null,
|
|
168
|
+
created_at timestamptz not null default now(),
|
|
169
|
+
primary key (bucket, key)
|
|
170
|
+
)`);
|
|
171
|
+
return new PgObjectStore(sql);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* AWS SigV4 verification for the S3 wire protocol (spec § 2 auth). The payload
|
|
175
|
+
* hash comes from the client — `x-amz-content-sha256` (a real hash or
|
|
176
|
+
* `UNSIGNED-PAYLOAD`) for header auth, `UNSIGNED-PAYLOAD` for presign — and is
|
|
177
|
+
* never re-hashed; the verifier trusts what was signed, like a real S3 endpoint.
|
|
178
|
+
* Runtime engine code (`node:crypto`); not re-exported from the authoring barrel.
|
|
179
|
+
*/
|
|
180
|
+
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
181
|
+
const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
182
|
+
function sha256Hex(data) {
|
|
183
|
+
return createHash("sha256").update(data).digest("hex");
|
|
184
|
+
}
|
|
185
|
+
function hmac(key, data) {
|
|
186
|
+
return createHmac("sha256", key).update(data).digest();
|
|
187
|
+
}
|
|
188
|
+
/** AWS canonical URI encoding: every byte except the unreserved set is %XX. */
|
|
189
|
+
function awsUriEncode(value) {
|
|
190
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`).replace(/%7E/g, "~");
|
|
191
|
+
}
|
|
192
|
+
function parseCredential(credential) {
|
|
193
|
+
const parts = credential.split("/");
|
|
194
|
+
if (parts.length !== 5 || parts[4] !== "aws4_request") return null;
|
|
195
|
+
const [accessKeyId, date, region, service] = parts;
|
|
196
|
+
if (!accessKeyId || !date || !region || !service) return null;
|
|
197
|
+
return {
|
|
198
|
+
accessKeyId,
|
|
199
|
+
date,
|
|
200
|
+
region,
|
|
201
|
+
service
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function signingKey(secret, scope) {
|
|
205
|
+
return hmac(hmac(hmac(hmac(`AWS4${secret}`, scope.date), scope.region), scope.service), "aws4_request");
|
|
206
|
+
}
|
|
207
|
+
function canonicalHeaders(url, req, signedHeaders) {
|
|
208
|
+
return signedHeaders.map((name) => {
|
|
209
|
+
return `${name}:${(name === "host" ? url.host : req.headers.get(name) ?? "").trim().replace(/\s+/g, " ")}\n`;
|
|
210
|
+
}).join("");
|
|
211
|
+
}
|
|
212
|
+
function canonicalQuery(url, exclude) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
215
|
+
if (exclude !== void 0 && key === exclude) continue;
|
|
216
|
+
entries.push([awsUriEncode(key), awsUriEncode(value)]);
|
|
217
|
+
}
|
|
218
|
+
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
219
|
+
entries.sort(([ak, av], [bk, bv]) => cmp(ak, bk) || cmp(av, bv));
|
|
220
|
+
return entries.map(([k, v]) => `${k}=${v}`).join("&");
|
|
221
|
+
}
|
|
222
|
+
function stringToSign(amzDate, scope, canonicalRequest) {
|
|
223
|
+
const scopeString = `${scope.date}/${scope.region}/${scope.service}/aws4_request`;
|
|
224
|
+
return [
|
|
225
|
+
ALGORITHM,
|
|
226
|
+
amzDate,
|
|
227
|
+
scopeString,
|
|
228
|
+
sha256Hex(canonicalRequest)
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
function signatureMatches(expected, provided) {
|
|
232
|
+
const a = Buffer.from(expected, "hex");
|
|
233
|
+
const b = Buffer.from(provided, "hex");
|
|
234
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
235
|
+
}
|
|
236
|
+
/** `YYYYMMDDTHHMMSSZ` → epoch ms, or null when malformed. */
|
|
237
|
+
function parseAmzDate(amzDate) {
|
|
238
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(amzDate);
|
|
239
|
+
if (!match) return null;
|
|
240
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
241
|
+
return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
242
|
+
}
|
|
243
|
+
function parseAuthorizationHeader(header) {
|
|
244
|
+
if (!header.startsWith(`${ALGORITHM} `)) return null;
|
|
245
|
+
const rest = header.slice(17);
|
|
246
|
+
const fields = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const part of rest.split(",")) {
|
|
248
|
+
const eq = part.indexOf("=");
|
|
249
|
+
if (eq === -1) continue;
|
|
250
|
+
fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
|
|
251
|
+
}
|
|
252
|
+
const credential = fields.get("Credential");
|
|
253
|
+
const signedHeaders = fields.get("SignedHeaders");
|
|
254
|
+
const signature = fields.get("Signature");
|
|
255
|
+
if (!credential || !signedHeaders || !signature) return null;
|
|
256
|
+
return {
|
|
257
|
+
credential,
|
|
258
|
+
signedHeaders: signedHeaders.split(";"),
|
|
259
|
+
signature
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** The one signing core both auth forms share: check the access key, rebuild the canonical request, derive the key, compare in constant time. */
|
|
263
|
+
function verifySignature(req, url, credentials, params) {
|
|
264
|
+
if (params.scope.accessKeyId !== credentials.accessKeyId) return {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: "unknown access key"
|
|
267
|
+
};
|
|
268
|
+
const canonicalRequest = [
|
|
269
|
+
req.method,
|
|
270
|
+
url.pathname,
|
|
271
|
+
canonicalQuery(url, params.excludeQuery),
|
|
272
|
+
canonicalHeaders(url, req, params.signedHeaders),
|
|
273
|
+
params.signedHeaders.join(";"),
|
|
274
|
+
params.payloadHash
|
|
275
|
+
].join("\n");
|
|
276
|
+
return signatureMatches(hmac(signingKey(credentials.secretAccessKey, params.scope), stringToSign(params.amzDate, params.scope, canonicalRequest)).toString("hex"), params.signature) ? { ok: true } : {
|
|
277
|
+
ok: false,
|
|
278
|
+
reason: "signature mismatch"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function verifyHeader(req, url, credentials) {
|
|
282
|
+
const auth = parseAuthorizationHeader(req.headers.get("authorization") ?? "");
|
|
283
|
+
if (!auth) return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: "malformed Authorization header"
|
|
286
|
+
};
|
|
287
|
+
const scope = parseCredential(auth.credential);
|
|
288
|
+
if (!scope) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
reason: "malformed credential scope"
|
|
291
|
+
};
|
|
292
|
+
const amzDate = req.headers.get("x-amz-date");
|
|
293
|
+
if (!amzDate) return {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: "missing x-amz-date"
|
|
296
|
+
};
|
|
297
|
+
const payloadHash = req.headers.get("x-amz-content-sha256");
|
|
298
|
+
if (!payloadHash) return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: "missing x-amz-content-sha256"
|
|
301
|
+
};
|
|
302
|
+
return verifySignature(req, url, credentials, {
|
|
303
|
+
scope,
|
|
304
|
+
amzDate,
|
|
305
|
+
signedHeaders: auth.signedHeaders,
|
|
306
|
+
payloadHash,
|
|
307
|
+
signature: auth.signature
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function verifyPresigned(req, url, credentials, now) {
|
|
311
|
+
const q = url.searchParams;
|
|
312
|
+
if (q.get("X-Amz-Algorithm") !== ALGORITHM) return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: "unsupported presign algorithm"
|
|
315
|
+
};
|
|
316
|
+
const credentialRaw = q.get("X-Amz-Credential");
|
|
317
|
+
const amzDate = q.get("X-Amz-Date");
|
|
318
|
+
const expiresRaw = q.get("X-Amz-Expires");
|
|
319
|
+
const signedHeadersRaw = q.get("X-Amz-SignedHeaders");
|
|
320
|
+
const signature = q.get("X-Amz-Signature");
|
|
321
|
+
if (!credentialRaw || !amzDate || !expiresRaw || !signedHeadersRaw || !signature) return {
|
|
322
|
+
ok: false,
|
|
323
|
+
reason: "incomplete presign parameters"
|
|
324
|
+
};
|
|
325
|
+
const scope = parseCredential(credentialRaw);
|
|
326
|
+
if (!scope) return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: "malformed credential scope"
|
|
329
|
+
};
|
|
330
|
+
const signedAt = parseAmzDate(amzDate);
|
|
331
|
+
const expires = Number(expiresRaw);
|
|
332
|
+
if (signedAt === null || !Number.isFinite(expires)) return {
|
|
333
|
+
ok: false,
|
|
334
|
+
reason: "malformed presign date"
|
|
335
|
+
};
|
|
336
|
+
if (now.getTime() > signedAt + expires * 1e3) return {
|
|
337
|
+
ok: false,
|
|
338
|
+
reason: "presign expired"
|
|
339
|
+
};
|
|
340
|
+
return verifySignature(req, url, credentials, {
|
|
341
|
+
scope,
|
|
342
|
+
amzDate,
|
|
343
|
+
signedHeaders: signedHeadersRaw.split(";"),
|
|
344
|
+
payloadHash: UNSIGNED_PAYLOAD,
|
|
345
|
+
signature,
|
|
346
|
+
excludeQuery: "X-Amz-Signature"
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Verify a request's SigV4 signature against a single credential pair. Picks
|
|
351
|
+
* the presigned form when `X-Amz-Signature` is present, otherwise the
|
|
352
|
+
* `Authorization`-header form. `now` is injectable for deterministic
|
|
353
|
+
* expiry tests.
|
|
354
|
+
*/
|
|
355
|
+
function verifyRequest(req, credentials, now = /* @__PURE__ */ new Date()) {
|
|
356
|
+
const url = new URL(req.url);
|
|
357
|
+
if (url.searchParams.has("X-Amz-Signature")) return verifyPresigned(req, url, credentials, now);
|
|
358
|
+
if (req.headers.has("authorization")) return verifyHeader(req, url, credentials);
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
reason: "unsigned request"
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
365
|
+
/** Path-style: `/{bucket}/{key…}`. Each segment is percent-decoded. */
|
|
366
|
+
function parseTarget(url) {
|
|
367
|
+
const segments = url.pathname.split("/").filter((s) => s.length > 0);
|
|
368
|
+
if (segments.length === 0) return null;
|
|
369
|
+
const [bucket, ...keyParts] = segments;
|
|
370
|
+
return {
|
|
371
|
+
bucket: decodeURIComponent(bucket ?? ""),
|
|
372
|
+
key: keyParts.map(decodeURIComponent).join("/")
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** `bytes=a-b` (inclusive) or `bytes=a-` (open-ended). Null when absent/malformed. */
|
|
376
|
+
function parseRange(header) {
|
|
377
|
+
if (!header) return null;
|
|
378
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
|
|
379
|
+
if (!match) return null;
|
|
380
|
+
const start = Number(match[1]);
|
|
381
|
+
return match[2] ? {
|
|
382
|
+
start,
|
|
383
|
+
end: Number(match[2])
|
|
384
|
+
} : { start };
|
|
385
|
+
}
|
|
386
|
+
function xmlEscape(value) {
|
|
387
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
388
|
+
}
|
|
389
|
+
function listXml(bucket, prefix, maxKeys, result) {
|
|
390
|
+
const contents = result.keys.map((k) => `<Contents><Key>${xmlEscape(k)}</Key></Contents>`).join("");
|
|
391
|
+
const next = result.isTruncated && result.nextContinuationToken !== void 0 ? `<NextContinuationToken>${xmlEscape(result.nextContinuationToken)}</NextContinuationToken>` : "";
|
|
392
|
+
return `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>${xmlEscape(bucket)}</Name><Prefix>${xmlEscape(prefix)}</Prefix><KeyCount>${result.keys.length}</KeyCount><MaxKeys>${maxKeys}</MaxKeys><IsTruncated>${result.isTruncated}</IsTruncated>` + contents + next + "</ListBucketResult>";
|
|
393
|
+
}
|
|
394
|
+
const DEFAULT_MAX_KEYS = 1e3;
|
|
395
|
+
async function handleList(store, bucket, url) {
|
|
396
|
+
const prefix = url.searchParams.get("prefix") ?? "";
|
|
397
|
+
const continuationToken = url.searchParams.get("continuation-token");
|
|
398
|
+
const maxKeysRaw = url.searchParams.get("max-keys");
|
|
399
|
+
const maxKeys = maxKeysRaw !== null && Number.isFinite(Number(maxKeysRaw)) ? Number(maxKeysRaw) : DEFAULT_MAX_KEYS;
|
|
400
|
+
const result = await store.list(bucket, {
|
|
401
|
+
prefix,
|
|
402
|
+
maxKeys,
|
|
403
|
+
...continuationToken !== null ? { continuationToken } : {}
|
|
404
|
+
});
|
|
405
|
+
return new Response(listXml(bucket, prefix, maxKeys, result), {
|
|
406
|
+
status: 200,
|
|
407
|
+
headers: { "content-type": "application/xml" }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** aws-chunked / flexible-checksum PUTs frame the body as chunks + a trailer (signalled by `x-amz-content-sha256: STREAMING-…` or `content-encoding: aws-chunked`); the seed signature still verifies, so reject them (501) rather than store the raw framing as the object bytes. Decoding is out of scope. */
|
|
411
|
+
function isStreamingPut(req) {
|
|
412
|
+
const contentSha = req.headers.get("x-amz-content-sha256") ?? "";
|
|
413
|
+
const contentEncoding = req.headers.get("content-encoding") ?? "";
|
|
414
|
+
return contentSha.startsWith("STREAMING-") || contentEncoding.split(",").some((e) => e.trim() === "aws-chunked");
|
|
415
|
+
}
|
|
416
|
+
async function handlePut(store, t, req) {
|
|
417
|
+
if (isStreamingPut(req)) return new Response("aws-chunked / flexible checksums not supported; set requestChecksumCalculation: 'WHEN_REQUIRED'", { status: 501 });
|
|
418
|
+
const body = new Uint8Array(await req.arrayBuffer());
|
|
419
|
+
const contentType = req.headers.get("content-type") ?? DEFAULT_CONTENT_TYPE;
|
|
420
|
+
const { etag } = await store.put(t.bucket, t.key, body, { contentType });
|
|
421
|
+
return new Response(null, {
|
|
422
|
+
status: 200,
|
|
423
|
+
headers: { etag }
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/** The etag/content-type/content-length/accept-ranges headers GET and HEAD share — `contentLength` is the slice length for GET, the total object size for HEAD. */
|
|
427
|
+
function metaHeaders(meta) {
|
|
428
|
+
return new Headers({
|
|
429
|
+
etag: meta.etag,
|
|
430
|
+
"content-type": meta.contentType,
|
|
431
|
+
"content-length": String(meta.contentLength),
|
|
432
|
+
"accept-ranges": "bytes"
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async function handleGet(store, t, req) {
|
|
436
|
+
const range = parseRange(req.headers.get("range"));
|
|
437
|
+
const object = await store.get(t.bucket, t.key, range ? { range } : void 0);
|
|
438
|
+
if (!object) return new Response(null, { status: 404 });
|
|
439
|
+
const headers = metaHeaders({
|
|
440
|
+
etag: object.etag,
|
|
441
|
+
contentType: object.contentType,
|
|
442
|
+
contentLength: object.bytes.byteLength
|
|
443
|
+
});
|
|
444
|
+
if (!range) return new Response(object.bytes, {
|
|
445
|
+
status: 200,
|
|
446
|
+
headers
|
|
447
|
+
});
|
|
448
|
+
if (range.start >= object.size && object.size > 0) return new Response(null, {
|
|
449
|
+
status: 416,
|
|
450
|
+
headers: { "content-range": `bytes */${object.size}` }
|
|
451
|
+
});
|
|
452
|
+
const end = range.end === void 0 ? object.size - 1 : Math.min(range.end, object.size - 1);
|
|
453
|
+
headers.set("content-range", `bytes ${range.start}-${end}/${object.size}`);
|
|
454
|
+
return new Response(object.bytes, {
|
|
455
|
+
status: 206,
|
|
456
|
+
headers
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async function handleHead(store, t) {
|
|
460
|
+
const meta = await store.head(t.bucket, t.key);
|
|
461
|
+
if (!meta) return new Response(null, { status: 404 });
|
|
462
|
+
return new Response(null, {
|
|
463
|
+
status: 200,
|
|
464
|
+
headers: metaHeaders({
|
|
465
|
+
etag: meta.etag,
|
|
466
|
+
contentType: meta.contentType,
|
|
467
|
+
contentLength: meta.size
|
|
468
|
+
})
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async function handleDelete(store, t) {
|
|
472
|
+
await store.delete(t.bucket, t.key);
|
|
473
|
+
return new Response(null, { status: 204 });
|
|
474
|
+
}
|
|
475
|
+
function createS3Handler(opts) {
|
|
476
|
+
const { store, credentials } = opts;
|
|
477
|
+
return async (req) => {
|
|
478
|
+
if (!verifyRequest(req, credentials).ok) return new Response(null, { status: 403 });
|
|
479
|
+
const url = new URL(req.url);
|
|
480
|
+
const target = parseTarget(url);
|
|
481
|
+
if (!target) return new Response(null, { status: 400 });
|
|
482
|
+
if (req.method === "GET" && url.searchParams.get("list-type") === "2" && target.key === "") return handleList(store, target.bucket, url);
|
|
483
|
+
if (target.key === "") return new Response(null, { status: 400 });
|
|
484
|
+
switch (req.method) {
|
|
485
|
+
case "PUT": return handlePut(store, target, req);
|
|
486
|
+
case "GET": return handleGet(store, target, req);
|
|
487
|
+
case "HEAD": return handleHead(store, target);
|
|
488
|
+
case "DELETE": return handleDelete(store, target);
|
|
489
|
+
default: return new Response(null, { status: 405 });
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Boots the S3 wire protocol on `Bun.serve` — the D2 handler over any
|
|
495
|
+
* `ObjectStore`. Binds all interfaces (Compute routes external HTTP to the VM,
|
|
496
|
+
* so a loopback-only listener would be unreachable). Installs the FT-5219
|
|
497
|
+
* process guards so an idle Bun.SQL connection close surfaces as a logged
|
|
498
|
+
* error instead of crash-looping the process on scale-to-zero.
|
|
499
|
+
*
|
|
500
|
+
* Runtime engine code; NOT re-exported from the authoring barrel. The D4
|
|
501
|
+
* entrypoint reads deps via `load()` and calls this.
|
|
502
|
+
*/
|
|
503
|
+
let guardsInstalled = false;
|
|
504
|
+
/** FT-5219: keep the process alive when Bun.SQL surfaces an idle-close as an unawaited async error. Installed once. */
|
|
505
|
+
function installProcessGuards() {
|
|
506
|
+
if (guardsInstalled) return;
|
|
507
|
+
guardsInstalled = true;
|
|
508
|
+
process.on("uncaughtException", (err) => console.error("uncaughtException", err));
|
|
509
|
+
process.on("unhandledRejection", (err) => console.error("unhandledRejection", err));
|
|
510
|
+
}
|
|
511
|
+
function startStorageServer(opts) {
|
|
512
|
+
installProcessGuards();
|
|
513
|
+
const handler = createS3Handler({
|
|
514
|
+
store: opts.store,
|
|
515
|
+
credentials: opts.credentials
|
|
516
|
+
});
|
|
517
|
+
const hostname = opts.hostname ?? "0.0.0.0";
|
|
518
|
+
const server = Bun.serve({
|
|
519
|
+
port: opts.port,
|
|
520
|
+
hostname,
|
|
521
|
+
fetch: (req) => handler(req)
|
|
522
|
+
});
|
|
523
|
+
return {
|
|
524
|
+
url: `http://${hostname === "0.0.0.0" ? "127.0.0.1" : hostname}:${server.port}`,
|
|
525
|
+
stop: () => server.stop(true)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* **Last-resort escape hatch for unsafe type assertions. Not a sanctioned tool to reach for.**
|
|
530
|
+
*
|
|
531
|
+
* Before reaching for `blindCast`, **rewrite the surrounding code so the cast becomes
|
|
532
|
+
* unnecessary**: tighten an input type, add a runtime check that narrows via a type
|
|
533
|
+
* predicate, restructure a generic so the compiler can see the relationship you're
|
|
534
|
+
* asserting, or use {@link castAs} when the value already satisfies the target type.
|
|
535
|
+
* Only when no rewrite is feasible does `blindCast` become the right answer — and at
|
|
536
|
+
* that point, the `Reason` literal you supply must articulate the compromise in
|
|
537
|
+
* language a reviewer can evaluate.
|
|
538
|
+
*
|
|
539
|
+
* The reviewer **will** validate the `Reason`. If it doesn't hold up under scrutiny,
|
|
540
|
+
* that is not a signal to soften the reason; it is a signal to go back and solve the
|
|
541
|
+
* underlying type-system problem properly. An unconvincing justification is rework,
|
|
542
|
+
* not a free pass.
|
|
543
|
+
*
|
|
544
|
+
* `blindCast` is the auditable form of `as Foo` / `as unknown as Foo`: it bypasses
|
|
545
|
+
* the compiler's checks (the input type is `unknown`, the output type is whatever the
|
|
546
|
+
* caller asks for), but it forces the unsafety to be named at the call site instead of
|
|
547
|
+
* smuggled in via a bare `as`. The `Reason` type parameter exists only at compile
|
|
548
|
+
* time — it is not present in the emitted JavaScript — but it is grep-able and
|
|
549
|
+
* visible to future readers.
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* ```typescript
|
|
553
|
+
* const stringValue = blindCast<
|
|
554
|
+
* string,
|
|
555
|
+
* "JSON.parse returns `unknown`; this field is documented to be a string in the API contract"
|
|
556
|
+
* >(parsed[key]);
|
|
557
|
+
* ```
|
|
558
|
+
*
|
|
559
|
+
* @typeParam TargetType - The type the caller is asserting the input has.
|
|
560
|
+
* @typeParam _Reason - A string literal describing why bypassing the type system is necessary here.
|
|
561
|
+
* Only meaningful at compile time. The reviewer evaluates whether it justifies the unsafety.
|
|
562
|
+
*/
|
|
563
|
+
function blindCast(input) {
|
|
564
|
+
return input;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Core model: node types and the factories that construct them, plain frozen
|
|
568
|
+
* data objects. A node's `extension` + `type` form its deploy-time registry key (ADR-0017).
|
|
569
|
+
*/
|
|
570
|
+
const NODE = Symbol.for("prisma:node");
|
|
571
|
+
function requireType(type, factory) {
|
|
572
|
+
if (typeof type !== "string" || type.length === 0) throw new Error(`${factory}() requires a non-empty node type.`);
|
|
573
|
+
}
|
|
574
|
+
function requireName(name, factory) {
|
|
575
|
+
if (typeof name !== "string" || name.length === 0) throw new Error(`${factory}() requires a non-empty name.`);
|
|
576
|
+
}
|
|
577
|
+
function requireExtension(extension, factory) {
|
|
578
|
+
if (typeof extension !== "string" || extension.length === 0) throw new Error(`${factory}() requires a non-empty extension (the authoring extension's package name).`);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Config keys join address/input/param names with "_" and uppercase — an
|
|
582
|
+
* underscore inside a name would collide with that separator (e.g. param
|
|
583
|
+
* "db_url" vs input "db"'s param "url" both hitting env key "DB_URL").
|
|
584
|
+
*/
|
|
585
|
+
function requireNoUnderscoreName(name, kind, factory) {
|
|
586
|
+
if (name.includes("_")) throw new Error(`${factory}() ${kind} name "${name}" may not contain "_" — config keys join names with "_" as the separator (e.g. an input "db"'s param "url" becomes env key "DB_URL"), so an underscore inside a name would collide with that separator.`);
|
|
587
|
+
}
|
|
588
|
+
function requireNoUnderscoreNames(names, kind, factory) {
|
|
589
|
+
for (const name of names) requireNoUnderscoreName(name, kind, factory);
|
|
590
|
+
}
|
|
591
|
+
function freezeParams(params) {
|
|
592
|
+
const frozen = {};
|
|
593
|
+
for (const [name, param] of Object.entries(params)) frozen[name] = Object.freeze({ ...param });
|
|
594
|
+
return Object.freeze(frozen);
|
|
595
|
+
}
|
|
596
|
+
function freezeSecrets(secrets) {
|
|
597
|
+
const frozen = {};
|
|
598
|
+
for (const [name, need] of Object.entries(secrets)) frozen[name] = Object.freeze({ ...need });
|
|
599
|
+
return blindCast(Object.freeze(frozen));
|
|
600
|
+
}
|
|
601
|
+
/** A frozen shallow copy that keeps the caller's declared type. */
|
|
602
|
+
function frozenShallowCopy(obj) {
|
|
603
|
+
return blindCast(Object.freeze({ ...obj }));
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Seals a node instance after its constructor has assigned all fields — the
|
|
607
|
+
* last statement of a concrete node class's constructor. A free function, not
|
|
608
|
+
* a base-class method, so an instance stays structurally a plain frozen node.
|
|
609
|
+
*/
|
|
610
|
+
function freezeNode(node) {
|
|
611
|
+
Object.freeze(node);
|
|
612
|
+
return node;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Everything `resource()` establishes, minus the freeze — an extension
|
|
616
|
+
* whose resource node carries extra fields extends this, assigns them, and
|
|
617
|
+
* calls `freezeNode(this)` as its constructor's last statement.
|
|
618
|
+
*/
|
|
619
|
+
var ResourceNodeBase = class {
|
|
620
|
+
[NODE] = true;
|
|
621
|
+
kind = "resource";
|
|
622
|
+
name;
|
|
623
|
+
extension;
|
|
624
|
+
type;
|
|
625
|
+
provides;
|
|
626
|
+
constructor(def) {
|
|
627
|
+
requireName(def.name, "resource");
|
|
628
|
+
requireExtension(def.extension, "resource");
|
|
629
|
+
const provides = def.provides;
|
|
630
|
+
if (typeof provides !== "object" || provides === null || typeof provides.kind !== "string" || provides.kind.length === 0 || typeof provides.satisfies !== "function") throw new Error("resource() requires `provides` — the Contract this resource offers (a non-empty `kind` plus its `satisfies()`).");
|
|
631
|
+
this.name = def.name;
|
|
632
|
+
this.extension = def.extension;
|
|
633
|
+
this.type = provides.kind;
|
|
634
|
+
this.provides = provides;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
/** The core leaf: exactly the base, frozen. */
|
|
638
|
+
var FrozenResourceNode = class extends ResourceNodeBase {
|
|
639
|
+
constructor(def) {
|
|
640
|
+
super(def);
|
|
641
|
+
freezeNode(this);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
/**
|
|
645
|
+
* Constructs a branded, frozen Resource node — an identity plus the Contract
|
|
646
|
+
* it provides; the routing `type` is the contract's `kind`. Pure — nothing
|
|
647
|
+
* is provisioned until a module provisions it.
|
|
648
|
+
*/
|
|
649
|
+
function resource(def) {
|
|
650
|
+
return new FrozenResourceNode(def);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Constructs a branded, frozen Service node — declarations only (inputs,
|
|
654
|
+
* params, build adapter, and the ports it exposes). Pure; carries no runtime behavior.
|
|
655
|
+
*/
|
|
656
|
+
function service$1(def) {
|
|
657
|
+
requireName(def.name, "service");
|
|
658
|
+
requireExtension(def.extension, "service");
|
|
659
|
+
requireType(def.type, "service");
|
|
660
|
+
requireNoUnderscoreNames(Object.keys(def.inputs), "input", "service");
|
|
661
|
+
requireNoUnderscoreNames(Object.keys(def.params), "param", "service");
|
|
662
|
+
requireNoUnderscoreNames(Object.keys(def.secrets ?? {}), "secret", "service");
|
|
663
|
+
for (const slot of Object.keys(def.secrets ?? {})) if (Object.hasOwn(def.params, slot)) throw new Error(`service() secret slot "${slot}" collides with a param of the same name — a secret slot and a service param derive the same config key (COMPOSE_<addr>_${slot.toUpperCase()}); rename one.`);
|
|
664
|
+
return Object.freeze({
|
|
665
|
+
[NODE]: true,
|
|
666
|
+
kind: "service",
|
|
667
|
+
name: def.name,
|
|
668
|
+
extension: def.extension,
|
|
669
|
+
type: def.type,
|
|
670
|
+
inputs: frozenShallowCopy(def.inputs),
|
|
671
|
+
params: freezeParams(def.params),
|
|
672
|
+
secretSlots: freezeSecrets(def.secrets ?? blindCast({})),
|
|
673
|
+
build: Object.freeze({ ...def.build }),
|
|
674
|
+
expose: def.expose !== void 0 ? frozenShallowCopy(def.expose) : void 0
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Constructs a branded, frozen DependencyEnd. `required` (if given) is the
|
|
679
|
+
* contract Load compares a wired ref against via `satisfies()`; an unnamed
|
|
680
|
+
* end's diagnostic `name` falls back to its `type`.
|
|
681
|
+
*/
|
|
682
|
+
function dependency(def) {
|
|
683
|
+
requireType(def.type, "dependency");
|
|
684
|
+
requireNoUnderscoreNames(Object.keys(def.connection.params), "param", "dependency");
|
|
685
|
+
const connection = Object.freeze({
|
|
686
|
+
params: freezeParams(def.connection.params),
|
|
687
|
+
hydrate: def.connection.hydrate
|
|
688
|
+
});
|
|
689
|
+
return Object.freeze({
|
|
690
|
+
[NODE]: true,
|
|
691
|
+
kind: "dependency",
|
|
692
|
+
name: def.name !== void 0 && def.name.length > 0 ? def.name : def.type,
|
|
693
|
+
type: def.type,
|
|
694
|
+
connection,
|
|
695
|
+
required: def.required
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* A value wrapper that redacts everywhere except the one explicit reader,
|
|
700
|
+
* `expose()`. Sensitivity is carried by the TYPE (`SecretBox<T>`), not a flag a
|
|
701
|
+
* sink must remember to check: `String(box)`, template interpolation,
|
|
702
|
+
* `JSON.stringify`, and `console.log`/`util.inspect` all print `[REDACTED]`, so
|
|
703
|
+
* a secret can't leak through an accidental log or serialization.
|
|
704
|
+
*
|
|
705
|
+
* Shape matches the platform's own `secrecy` type (pdp-control-plane). The class
|
|
706
|
+
* is nominal enough on its own — no phantom brand.
|
|
707
|
+
*/
|
|
708
|
+
const REDACTED = "[REDACTED]";
|
|
709
|
+
var SecretBox = class {
|
|
710
|
+
#value;
|
|
711
|
+
constructor(value) {
|
|
712
|
+
this.#value = value;
|
|
713
|
+
}
|
|
714
|
+
/** The sole explicit door to the wrapped value. */
|
|
715
|
+
expose() {
|
|
716
|
+
return this.#value;
|
|
717
|
+
}
|
|
718
|
+
toString() {
|
|
719
|
+
return REDACTED;
|
|
720
|
+
}
|
|
721
|
+
toJSON() {
|
|
722
|
+
return REDACTED;
|
|
723
|
+
}
|
|
724
|
+
valueOf() {
|
|
725
|
+
return REDACTED;
|
|
726
|
+
}
|
|
727
|
+
[Symbol.toPrimitive]() {
|
|
728
|
+
return REDACTED;
|
|
729
|
+
}
|
|
730
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
731
|
+
return REDACTED;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
function scalarSchema(name, check) {
|
|
735
|
+
return { "~standard": {
|
|
736
|
+
version: 1,
|
|
737
|
+
vendor: "@prisma/composer",
|
|
738
|
+
validate: (value) => check(value) ? { value } : { issues: [{ message: `expected ${name}, got ${typeof value}` }] }
|
|
739
|
+
} };
|
|
740
|
+
}
|
|
741
|
+
const stringSchema = scalarSchema("string", (v) => typeof v === "string");
|
|
742
|
+
const numberSchema = scalarSchema("number", (v) => typeof v === "number" && Number.isFinite(v));
|
|
743
|
+
function withFacets(schema, opts) {
|
|
744
|
+
return {
|
|
745
|
+
schema,
|
|
746
|
+
...opts.optional !== void 0 ? { optional: opts.optional } : {},
|
|
747
|
+
...opts.default !== void 0 ? { default: opts.default } : {}
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** A string-valued param. */
|
|
751
|
+
function string(opts = {}) {
|
|
752
|
+
return withFacets(stringSchema, opts);
|
|
753
|
+
}
|
|
754
|
+
/** A number-valued param. */
|
|
755
|
+
function number(opts = {}) {
|
|
756
|
+
return withFacets(numberSchema, opts);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Synchronous hydrate — what the node's `load()` uses so
|
|
760
|
+
* `const { db } = service.load()` reads without `await`. Requires every
|
|
761
|
+
* connection.hydrate to return synchronously; a Promise return is a loud error
|
|
762
|
+
* naming the input (an async client factory must use the async `hydrate` path).
|
|
763
|
+
*/
|
|
764
|
+
function hydrateSync(root, config) {
|
|
765
|
+
const deps = {};
|
|
766
|
+
for (const [name, inputNode] of Object.entries(root.inputs)) {
|
|
767
|
+
const values = config.inputs[name] ?? {};
|
|
768
|
+
const client = inputNode.connection.hydrate(values);
|
|
769
|
+
if (client instanceof Promise) throw new Error(`Connection hydrate for input "${name}" returned a Promise; load() requires a synchronous client factory.`);
|
|
770
|
+
deps[name] = client;
|
|
771
|
+
}
|
|
772
|
+
return deps;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Wraps each of a service's resolved secret values in a redacting `SecretBox`
|
|
776
|
+
* — what the node's `secrets()` accessor returns (ADR-0021, sibling to
|
|
777
|
+
* `load()`/`config()`). The RESOLUTION of a secret's value (the boot
|
|
778
|
+
* double-lookup that reads the platform var the pointer names) is the target
|
|
779
|
+
* pack's job; core is handed the already-resolved strings and only boxes them,
|
|
780
|
+
* so a secret is redacted by TYPE from here on. A declared slot missing from
|
|
781
|
+
* `values` is a target contract violation, named loudly.
|
|
782
|
+
*/
|
|
783
|
+
function hydrateSecrets(root, values) {
|
|
784
|
+
const boxed = {};
|
|
785
|
+
for (const slot of Object.keys(root.secretSlots)) {
|
|
786
|
+
const value = values[slot];
|
|
787
|
+
if (value === void 0) throw new Error(`secret slot "${slot}" has no resolved value — the target must resolve every declared secret before hydrateSecrets().`);
|
|
788
|
+
boxed[slot] = new SecretBox(value);
|
|
789
|
+
}
|
|
790
|
+
return blindCast(boxed);
|
|
791
|
+
}
|
|
792
|
+
const nodeBuild = (opts) => ({
|
|
793
|
+
extension: "@prisma/composer/node",
|
|
794
|
+
type: "node",
|
|
795
|
+
module: opts.module,
|
|
796
|
+
entry: opts.entry
|
|
797
|
+
});
|
|
798
|
+
/**
|
|
799
|
+
* Walks a node's own params, then each dependency input's connection params —
|
|
800
|
+
* the same enumeration order `configOf` uses, but carrying the raw
|
|
801
|
+
* `ConfigParam` (with its `serialize`/`deserialize`) instead of a pure-data
|
|
802
|
+
* projection.
|
|
803
|
+
*/
|
|
804
|
+
function paramEntries(node) {
|
|
805
|
+
const entries = [];
|
|
806
|
+
for (const [input, value] of Object.entries(node.inputs)) {
|
|
807
|
+
if (typeof value !== "object" || value === null) continue;
|
|
808
|
+
const params = blindCast(value).connection.params;
|
|
809
|
+
for (const [name, param] of Object.entries(params)) entries.push({
|
|
810
|
+
owner: { input },
|
|
811
|
+
name,
|
|
812
|
+
param
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
for (const [name, param] of Object.entries(node.params)) entries.push({
|
|
816
|
+
owner: "service",
|
|
817
|
+
name,
|
|
818
|
+
param
|
|
819
|
+
});
|
|
820
|
+
return entries;
|
|
821
|
+
}
|
|
822
|
+
const configKey = (address, d) => {
|
|
823
|
+
const segments = address.split(".").filter((s) => s.length > 0);
|
|
824
|
+
const owner = d.owner === "service" ? [] : [d.owner.input];
|
|
825
|
+
return [
|
|
826
|
+
"COMPOSE",
|
|
827
|
+
...segments,
|
|
828
|
+
...owner,
|
|
829
|
+
d.name
|
|
830
|
+
].join("_").toUpperCase();
|
|
831
|
+
};
|
|
832
|
+
/**
|
|
833
|
+
* Typed value → its stored string. Service-own literals are JSON-encoded; a
|
|
834
|
+
* dependency-input value is a provisioning ref at deploy (and a resolved
|
|
835
|
+
* string at boot) and passes through untouched — LANDMINE: JSON-encoding it
|
|
836
|
+
* would break the ordering edge Alchemy resolves through it.
|
|
837
|
+
*/
|
|
838
|
+
function encode(owner, value) {
|
|
839
|
+
return owner === "service" ? JSON.stringify(value) : blindCast(value);
|
|
840
|
+
}
|
|
841
|
+
/** Reverses `encode`: JSON-parse a service-own value, take a dependency-input value raw. */
|
|
842
|
+
function decode(owner, raw) {
|
|
843
|
+
return owner === "service" ? JSON.parse(raw) : raw;
|
|
844
|
+
}
|
|
845
|
+
function coerce(raw, d, key) {
|
|
846
|
+
if (!(raw !== void 0 && raw !== "")) {
|
|
847
|
+
if (d.param.default !== void 0) return d.param.default;
|
|
848
|
+
if (d.param.optional === true) return void 0;
|
|
849
|
+
throw new Error(`missing required config param "${d.name}" (env ${key})`);
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
return standardValidateSync(d.param.schema, decode(d.owner, raw));
|
|
853
|
+
} catch (cause) {
|
|
854
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
855
|
+
throw new Error(`invalid value for config param "${d.name}" (env ${key}): ${message}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Boot: read each declared param from env by its key, reverse the param's own
|
|
860
|
+
* serialization (missing/invalid fails loudly), assemble the typed Config.
|
|
861
|
+
* Secrets ride a separate channel (deserializeSecrets), not this one.
|
|
862
|
+
*/
|
|
863
|
+
const deserialize = (node, address) => {
|
|
864
|
+
const service = {};
|
|
865
|
+
const inputs = {};
|
|
866
|
+
for (const d of paramEntries(node)) {
|
|
867
|
+
const key = configKey(address, d);
|
|
868
|
+
const value = coerce(process.env[key], d, key);
|
|
869
|
+
if (d.owner === "service") service[d.name] = value;
|
|
870
|
+
else {
|
|
871
|
+
let bucket = inputs[d.owner.input];
|
|
872
|
+
if (bucket === void 0) {
|
|
873
|
+
bucket = {};
|
|
874
|
+
inputs[d.owner.input] = bucket;
|
|
875
|
+
}
|
|
876
|
+
bucket[d.name] = value;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
service,
|
|
881
|
+
inputs
|
|
882
|
+
};
|
|
883
|
+
};
|
|
884
|
+
/**
|
|
885
|
+
* run()'s setup step: write the resolved config to the environment under
|
|
886
|
+
* address-free keys (configKey("", d) + each serialize suffix), which load()
|
|
887
|
+
* reads back with no address. Uses env, not a module variable, because a
|
|
888
|
+
* framework may fork worker processes that inherit env but not memory.
|
|
889
|
+
* Writes only these keys; nothing else is touched.
|
|
890
|
+
*/
|
|
891
|
+
const stash = (node, config) => {
|
|
892
|
+
for (const d of paramEntries(node)) {
|
|
893
|
+
const value = d.owner === "service" ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name];
|
|
894
|
+
if (value === void 0) continue;
|
|
895
|
+
process.env[configKey("", d)] = encode(d.owner, value);
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
/** The pointer-row key for a secret slot: COMPOSE_<addr>_<slot> (secrets are service-level). */
|
|
899
|
+
const secretKey = (address, slot) => configKey(address, {
|
|
900
|
+
owner: "service",
|
|
901
|
+
name: slot
|
|
902
|
+
});
|
|
903
|
+
/**
|
|
904
|
+
* Boot: resolve every secret slot to its value by double-lookup — read the
|
|
905
|
+
* pointer key (the platform NAME), then read that platform var. A missing
|
|
906
|
+
* pointer or a missing/empty platform value is a loud failure naming both keys.
|
|
907
|
+
* Returns a plain Record for core's `hydrateSecrets` to box.
|
|
908
|
+
*/
|
|
909
|
+
const deserializeSecrets = (node, address) => {
|
|
910
|
+
const values = {};
|
|
911
|
+
for (const slot of Object.keys(node.secretSlots)) {
|
|
912
|
+
const key = secretKey(address, slot);
|
|
913
|
+
const name = process.env[key];
|
|
914
|
+
if (name === void 0 || name === "") throw new Error(`missing secret pointer for slot "${slot}" (env ${key}) — the deploy did not write it.`);
|
|
915
|
+
const value = process.env[name];
|
|
916
|
+
if (value === void 0 || value === "") throw new Error(`secret "${slot}" is not provisioned (env ${key} → ${name}): the platform var "${name}" is unset or empty.`);
|
|
917
|
+
values[slot] = value;
|
|
918
|
+
}
|
|
919
|
+
return values;
|
|
920
|
+
};
|
|
921
|
+
/**
|
|
922
|
+
* run()'s setup step for secrets: re-emit each slot's pointer NAME under its
|
|
923
|
+
* address-free key, so the address-free `deserializeSecrets` double-looks-up
|
|
924
|
+
* identically. Never the value — the value stays only in the platform var.
|
|
925
|
+
*/
|
|
926
|
+
const stashSecrets = (node, address) => {
|
|
927
|
+
for (const slot of Object.keys(node.secretSlots)) {
|
|
928
|
+
const name = process.env[secretKey(address, slot)];
|
|
929
|
+
if (name === void 0) continue;
|
|
930
|
+
process.env[secretKey("", slot)] = name;
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
/** Synchronous Standard Schema validation — see the matching note in core's `config.ts`. */
|
|
934
|
+
function standardValidateSync(schema, value) {
|
|
935
|
+
const result = schema["~standard"].validate(value);
|
|
936
|
+
if (result instanceof Promise) throw new Error("config param schema validation must be synchronous — async Standard Schema validators are not supported for config params");
|
|
937
|
+
if (result.issues !== void 0) throw new Error(`config param validation failed: ${result.issues.map((issue) => issue.message).join("; ")}`);
|
|
938
|
+
return result.value;
|
|
939
|
+
}
|
|
940
|
+
const reservedParams = { port: number({ default: 3e3 }) };
|
|
941
|
+
/**
|
|
942
|
+
* A Prisma Compute service — declarations only (deps + params + build + the
|
|
943
|
+
* ports it exposes), no descriptor. `params` merges with the reserved
|
|
944
|
+
* `ReservedParams` (`port`); a user param whose name collides with a reserved
|
|
945
|
+
* one fails at authoring, the same way a colliding dependency name does.
|
|
946
|
+
* Returns the extension's runnable/loadable node:
|
|
947
|
+
* · run(address, boot) — the process controller: deserialize the platform
|
|
948
|
+
* environment (keyed off `address`, the extension's ONE env read) into a
|
|
949
|
+
* typed Config, re-emit it under address-free process-local stash keys,
|
|
950
|
+
* then call boot() to start the app's entry.
|
|
951
|
+
* · load() / config() — called from inside the app's entry: read the stash;
|
|
952
|
+
* load() hydrates + memoizes the deps, config() returns the typed params.
|
|
953
|
+
* Separate accessors so a dep and a param never share a namespace (ADR-0021).
|
|
954
|
+
*
|
|
955
|
+
* `service()`'s underlying node carries `extension: '@prisma/composer-prisma-cloud'` —
|
|
956
|
+
* the control-plane registry key `prisma-composer deploy` resolves through the
|
|
957
|
+
* app's `prisma-composer.config.ts` (ADR-0017). This module loads nothing at
|
|
958
|
+
* deploy time; nodes are pure data.
|
|
959
|
+
*/
|
|
960
|
+
const compute = (def) => {
|
|
961
|
+
const userParams = def.params ?? blindCast({});
|
|
962
|
+
for (const reserved of Object.keys(reservedParams)) {
|
|
963
|
+
if (reserved in def.deps) throw new Error(`compute(): dependency "${reserved}" collides with the reserved service param of the same name — rename the dependency.`);
|
|
964
|
+
if (reserved in userParams) throw new Error(`compute(): param "${reserved}" collides with the reserved service param of the same name — rename the param.`);
|
|
965
|
+
}
|
|
966
|
+
const params = blindCast({
|
|
967
|
+
...userParams,
|
|
968
|
+
...reservedParams
|
|
969
|
+
});
|
|
970
|
+
const node = service$1({
|
|
971
|
+
name: def.name,
|
|
972
|
+
extension: "@prisma/composer-prisma-cloud",
|
|
973
|
+
type: "compute",
|
|
974
|
+
inputs: def.deps,
|
|
975
|
+
params,
|
|
976
|
+
...def.secrets !== void 0 ? { secrets: def.secrets } : {},
|
|
977
|
+
build: def.build,
|
|
978
|
+
...def.expose !== void 0 ? { expose: def.expose } : {}
|
|
979
|
+
});
|
|
980
|
+
let resolved;
|
|
981
|
+
let loadedDeps;
|
|
982
|
+
let loadedParams;
|
|
983
|
+
let loadedSecrets;
|
|
984
|
+
function processConfig() {
|
|
985
|
+
if (resolved === void 0) resolved = deserialize(node, "");
|
|
986
|
+
return resolved;
|
|
987
|
+
}
|
|
988
|
+
const runnable = {
|
|
989
|
+
...node,
|
|
990
|
+
async run(address, boot) {
|
|
991
|
+
const config = deserialize(node, address);
|
|
992
|
+
stash(node, config);
|
|
993
|
+
stashSecrets(node, address);
|
|
994
|
+
const port = config.service["port"];
|
|
995
|
+
if (typeof port === "number") process.env["PORT"] = String(port);
|
|
996
|
+
return boot();
|
|
997
|
+
},
|
|
998
|
+
load() {
|
|
999
|
+
if (loadedDeps === void 0) loadedDeps = blindCast(hydrateSync(node, processConfig()));
|
|
1000
|
+
return loadedDeps;
|
|
1001
|
+
},
|
|
1002
|
+
config() {
|
|
1003
|
+
if (loadedParams === void 0) loadedParams = blindCast(processConfig().service);
|
|
1004
|
+
return loadedParams;
|
|
1005
|
+
},
|
|
1006
|
+
secrets() {
|
|
1007
|
+
if (loadedSecrets === void 0) loadedSecrets = blindCast(hydrateSecrets(node, deserializeSecrets(node, "")));
|
|
1008
|
+
return loadedSecrets;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
return Object.freeze(blindCast(runnable));
|
|
1012
|
+
};
|
|
1013
|
+
/**
|
|
1014
|
+
* The contract a Postgres provides — and the contract its consumers require.
|
|
1015
|
+
* `satisfies` compares KIND, not identity: an extension module can be duplicated
|
|
1016
|
+
* across a workspace (same rationale as the Symbol.for node brand), and every
|
|
1017
|
+
* duplicate's contract must still satisfy. `__cmp` is the connection config a
|
|
1018
|
+
* postgres offers; core never inspects it.
|
|
1019
|
+
*/
|
|
1020
|
+
const postgresContract = Object.freeze({
|
|
1021
|
+
kind: "postgres",
|
|
1022
|
+
__cmp: { url: "" },
|
|
1023
|
+
satisfies: (required) => required.kind === "postgres"
|
|
1024
|
+
});
|
|
1025
|
+
function postgres(opts) {
|
|
1026
|
+
if (opts?.name !== void 0) return resource({
|
|
1027
|
+
name: opts.name,
|
|
1028
|
+
extension: "@prisma/composer-prisma-cloud",
|
|
1029
|
+
provides: postgresContract
|
|
1030
|
+
});
|
|
1031
|
+
return dependency({
|
|
1032
|
+
type: "postgres",
|
|
1033
|
+
connection: {
|
|
1034
|
+
params: { url: string() },
|
|
1035
|
+
hydrate: (v) => v
|
|
1036
|
+
},
|
|
1037
|
+
required: postgresContract
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* The contract the `s3-credentials` resource provides — a minted SigV4 key
|
|
1042
|
+
* pair. `satisfies` compares KIND only (mirrors `postgresContract`); `__cmp` is
|
|
1043
|
+
* the config the resource offers, which core never inspects.
|
|
1044
|
+
*/
|
|
1045
|
+
const credentialsContract = Object.freeze({
|
|
1046
|
+
kind: "credentials",
|
|
1047
|
+
__cmp: {
|
|
1048
|
+
accessKeyId: "",
|
|
1049
|
+
secretAccessKey: ""
|
|
1050
|
+
},
|
|
1051
|
+
satisfies: (required) => required.kind === "credentials"
|
|
1052
|
+
});
|
|
1053
|
+
function s3Credentials(opts) {
|
|
1054
|
+
if (opts?.name !== void 0) return resource({
|
|
1055
|
+
name: opts.name,
|
|
1056
|
+
extension: "@prisma/composer-prisma-cloud",
|
|
1057
|
+
provides: credentialsContract
|
|
1058
|
+
});
|
|
1059
|
+
return dependency({
|
|
1060
|
+
type: "credentials",
|
|
1061
|
+
connection: {
|
|
1062
|
+
params: {
|
|
1063
|
+
accessKeyId: string(),
|
|
1064
|
+
secretAccessKey: string()
|
|
1065
|
+
},
|
|
1066
|
+
hydrate: (v) => v
|
|
1067
|
+
},
|
|
1068
|
+
required: credentialsContract
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The storage service authoring factory — a `compute` service routed to the
|
|
1073
|
+
* `s3-store` lowering instead of `compute`'s. It is exactly `compute`'s
|
|
1074
|
+
* runnable (run/load/config, deps, params, build, expose) with the routing
|
|
1075
|
+
* `type` overridden to `'s3-store'`: nothing at runtime keys off `type` (the
|
|
1076
|
+
* serializer keys off the deployment address and each param's owner/name, and
|
|
1077
|
+
* `load`/`config` off deps/params), so only the deploy-time descriptor lookup
|
|
1078
|
+
* sees the override and routes to the extended-output lowering (§ 5). The
|
|
1079
|
+
* return type is compute's exactly (including the reserved `port` param). The
|
|
1080
|
+
* storage module (D4b) calls this with its `db`/`credentials` deps, a `bucket`
|
|
1081
|
+
* param, and `expose: { store: s3Contract }`.
|
|
1082
|
+
*/
|
|
1083
|
+
function s3StoreService(def) {
|
|
1084
|
+
const node = compute(def);
|
|
1085
|
+
return Object.freeze(blindCast({
|
|
1086
|
+
...node,
|
|
1087
|
+
type: "s3-store"
|
|
1088
|
+
}));
|
|
1089
|
+
}
|
|
1090
|
+
const s3Contract = Object.freeze({
|
|
1091
|
+
kind: "s3",
|
|
1092
|
+
__cmp: {
|
|
1093
|
+
url: "",
|
|
1094
|
+
bucket: "",
|
|
1095
|
+
accessKeyId: "",
|
|
1096
|
+
secretAccessKey: ""
|
|
1097
|
+
},
|
|
1098
|
+
satisfies: (required) => required.kind === "s3"
|
|
1099
|
+
});
|
|
1100
|
+
/**
|
|
1101
|
+
* The storage service node (like cron's `scheduler.ts` + `scheduler-service.ts`
|
|
1102
|
+
* combined): `storageService` builds the `s3-store` service — a Postgres `db`
|
|
1103
|
+
* dependency, a minted `credentials` dependency, a `bucket` param, and the
|
|
1104
|
+
* `store` port exposing `s3Contract`. The deploy bootstrap runs the
|
|
1105
|
+
* default-exported bare node (`main.run(address, boot)`); the real bucket comes
|
|
1106
|
+
* from serialized config at runtime, so the default's `bucket` is only a
|
|
1107
|
+
* placeholder — exactly like `scheduler-service.ts` default-exports
|
|
1108
|
+
* `cronScheduler({ jobs: [] })`.
|
|
1109
|
+
*/
|
|
1110
|
+
function storageService(opts) {
|
|
1111
|
+
return s3StoreService({
|
|
1112
|
+
name: "storage",
|
|
1113
|
+
deps: {
|
|
1114
|
+
db: postgres(),
|
|
1115
|
+
credentials: s3Credentials()
|
|
1116
|
+
},
|
|
1117
|
+
params: { bucket: string({ default: opts.bucket }) },
|
|
1118
|
+
build: nodeBuild({
|
|
1119
|
+
module: new URL("./storage-service.mjs", import.meta.url).href,
|
|
1120
|
+
entry: "./storage-entrypoint.mjs"
|
|
1121
|
+
}),
|
|
1122
|
+
expose: { store: s3Contract }
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
storageService({ bucket: "storage" });
|
|
1126
|
+
const service = storageService({ bucket: "storage" });
|
|
1127
|
+
const { db, credentials } = service.load();
|
|
1128
|
+
const { bucket, port } = service.config();
|
|
1129
|
+
startStorageServer({
|
|
1130
|
+
store: await createPgStore(db.url),
|
|
1131
|
+
credentials,
|
|
1132
|
+
bucket,
|
|
1133
|
+
port
|
|
1134
|
+
});
|
|
1135
|
+
//#endregion
|
|
1136
|
+
export {};
|
|
1137
|
+
|
|
1138
|
+
//# sourceMappingURL=storage-entrypoint.mjs.map
|