lakebed 0.0.7 → 0.0.9

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.
@@ -0,0 +1,454 @@
1
+ import { Buffer as NodeBuffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ const nodeProcess = process;
5
+ const nodeBuffer = NodeBuffer;
6
+ const sendToParent = typeof nodeProcess.send === "function" ? nodeProcess.send.bind(nodeProcess) : null;
7
+
8
+ const DEFAULT_LIMITS = {
9
+ instructionBudget: 50000,
10
+ maxValueBytes: 65536,
11
+ rowsReturned: 1000
12
+ };
13
+
14
+ let nextFetchId = 1;
15
+ const pendingFetches = new Map();
16
+
17
+ function stableStringify(value) {
18
+ if (value === undefined) {
19
+ return undefined;
20
+ }
21
+
22
+ if (value === null || typeof value !== "object") {
23
+ return JSON.stringify(value);
24
+ }
25
+
26
+ if (Array.isArray(value)) {
27
+ return `[${value.map((item) => stableStringify(item) ?? "null").join(",")}]`;
28
+ }
29
+
30
+ const entries = Object.entries(value)
31
+ .filter(([, entryValue]) => entryValue !== undefined)
32
+ .sort(([left], [right]) => left.localeCompare(right));
33
+
34
+ return `{${entries
35
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue) ?? "null"}`)
36
+ .join(",")}}`;
37
+ }
38
+
39
+ function byteLength(value) {
40
+ return nodeBuffer.byteLength(typeof value === "string" ? value : stableStringify(value), "utf8");
41
+ }
42
+
43
+ function isPlainObject(value) {
44
+ return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
45
+ }
46
+
47
+ function cloneJson(value) {
48
+ return JSON.parse(JSON.stringify(value));
49
+ }
50
+
51
+ function assertFieldValue(tableName, fieldName, field, value, limits = DEFAULT_LIMITS) {
52
+ if (value === undefined) {
53
+ throw new Error(`Missing value for ${tableName}.${fieldName}`);
54
+ }
55
+
56
+ if (field.kind === "string" && typeof value !== "string") {
57
+ throw new Error(`Expected ${tableName}.${fieldName} to be a string.`);
58
+ }
59
+
60
+ if (field.kind === "boolean" && typeof value !== "boolean") {
61
+ throw new Error(`Expected ${tableName}.${fieldName} to be a boolean.`);
62
+ }
63
+
64
+ const maxValueBytes = limits.maxValueBytes ?? DEFAULT_LIMITS.maxValueBytes;
65
+ if (byteLength(value) > maxValueBytes) {
66
+ throw new Error(`Value for ${tableName}.${fieldName} exceeds ${maxValueBytes} bytes.`);
67
+ }
68
+ }
69
+
70
+ function metadataFields() {
71
+ return new Set(["id", "createdAt", "updatedAt"]);
72
+ }
73
+
74
+ function prepareInsert(schema, tableName, value, limits = DEFAULT_LIMITS) {
75
+ const table = schema[tableName];
76
+ if (!table) {
77
+ throw new Error(`Unknown table: ${tableName}`);
78
+ }
79
+
80
+ const fields = table.fields ?? {};
81
+ const metadata = metadataFields();
82
+ for (const key of Object.keys(value)) {
83
+ if (!fields[key] && !metadata.has(key)) {
84
+ throw new Error(`Unknown field for ${tableName}: ${key}`);
85
+ }
86
+ if (metadata.has(key)) {
87
+ throw new Error(`Lakebed manages ${tableName}.${key}; app code cannot set it directly.`);
88
+ }
89
+ }
90
+
91
+ const timestamp = new Date().toISOString();
92
+ const row = {
93
+ id: randomUUID(),
94
+ createdAt: timestamp,
95
+ updatedAt: timestamp
96
+ };
97
+
98
+ for (const [fieldName, field] of Object.entries(fields)) {
99
+ const valueOrDefault = value[fieldName] ?? field.defaultValue;
100
+ assertFieldValue(tableName, fieldName, field, valueOrDefault, limits);
101
+ row[fieldName] = valueOrDefault;
102
+ }
103
+
104
+ return row;
105
+ }
106
+
107
+ function preparePatch(schema, tableName, patch, limits = DEFAULT_LIMITS) {
108
+ const table = schema[tableName];
109
+ if (!table) {
110
+ throw new Error(`Unknown table: ${tableName}`);
111
+ }
112
+
113
+ const cleanPatch = {};
114
+ const fields = table.fields ?? {};
115
+ const metadata = metadataFields();
116
+ for (const [key, value] of Object.entries(patch)) {
117
+ if (!fields[key] && !metadata.has(key)) {
118
+ throw new Error(`Unknown field for ${tableName}: ${key}`);
119
+ }
120
+ if (metadata.has(key)) {
121
+ throw new Error(`Lakebed manages ${tableName}.${key}; app code cannot update it directly.`);
122
+ }
123
+
124
+ assertFieldValue(tableName, key, fields[key], value, limits);
125
+ cleanPatch[key] = value;
126
+ }
127
+
128
+ cleanPatch.updatedAt = new Date().toISOString();
129
+ return cleanPatch;
130
+ }
131
+
132
+ function compareValues(left, right) {
133
+ if (left === right) {
134
+ return 0;
135
+ }
136
+ return left > right ? 1 : -1;
137
+ }
138
+
139
+ function createSourceQuery(rows, tableName, artifact) {
140
+ return {
141
+ filters: [],
142
+ limitValue: null,
143
+ orderByValue: null,
144
+ tableName,
145
+ where(field, value) {
146
+ return { ...this, filters: [...this.filters, { field, value }] };
147
+ },
148
+ orderBy(field, direction = "asc") {
149
+ return { ...this, orderByValue: { field, direction: direction === "desc" ? "desc" : "asc" } };
150
+ },
151
+ limit(count) {
152
+ const parsed = Number(count);
153
+ const safe = Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null;
154
+ return { ...this, limitValue: safe };
155
+ },
156
+ all() {
157
+ let results = [...rows[this.tableName]].map((row) => ({ ...row }));
158
+ for (const filter of this.filters) {
159
+ results = results.filter((row) => row[filter.field] === filter.value);
160
+ }
161
+ if (this.orderByValue) {
162
+ const direction = this.orderByValue.direction === "desc" ? -1 : 1;
163
+ results.sort((left, right) => compareValues(left[this.orderByValue.field], right[this.orderByValue.field]) * direction);
164
+ }
165
+ const maxRows = artifact.limits?.maxRowsReturned ?? DEFAULT_LIMITS.rowsReturned;
166
+ const requested = this.limitValue ?? maxRows;
167
+ const bounded = Math.max(0, Math.min(requested, maxRows));
168
+ return results.slice(0, bounded);
169
+ }
170
+ };
171
+ }
172
+
173
+ function createSourceContext({ artifact, auth, env, rows }) {
174
+ const workingRows = {};
175
+ const operations = [];
176
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
177
+ workingRows[tableName] = Array.isArray(rows?.[tableName]) ? rows[tableName].map((row) => ({ ...row })) : [];
178
+ }
179
+
180
+ const db = {};
181
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
182
+ db[tableName] = {
183
+ ...createSourceQuery(workingRows, tableName, artifact),
184
+ get(id) {
185
+ const row = workingRows[tableName].find((candidate) => candidate.id === id);
186
+ return row ? { ...row } : null;
187
+ },
188
+ insert(value) {
189
+ const row = prepareInsert(artifact.server.schema, tableName, value, artifact.limits);
190
+ workingRows[tableName].push(row);
191
+ operations.push({ op: "insert", row: { ...row }, table: tableName });
192
+ return { ...row };
193
+ },
194
+ update(id, patch) {
195
+ const index = workingRows[tableName].findIndex((row) => row.id === id);
196
+ if (index === -1) {
197
+ return;
198
+ }
199
+ const cleanPatch = preparePatch(artifact.server.schema, tableName, patch, artifact.limits);
200
+ workingRows[tableName][index] = { ...workingRows[tableName][index], ...cleanPatch };
201
+ operations.push({ id, op: "update", patch: cleanPatch, table: tableName });
202
+ },
203
+ delete(id) {
204
+ const index = workingRows[tableName].findIndex((row) => row.id === id);
205
+ if (index === -1) {
206
+ return;
207
+ }
208
+ workingRows[tableName].splice(index, 1);
209
+ operations.push({ id, op: "delete", table: tableName });
210
+ }
211
+ };
212
+ }
213
+
214
+ return {
215
+ ctx: {
216
+ auth,
217
+ db,
218
+ env,
219
+ log: {
220
+ error() {},
221
+ info() {},
222
+ warn() {}
223
+ }
224
+ },
225
+ operations
226
+ };
227
+ }
228
+
229
+ function headersToObject(headers) {
230
+ if (!headers) {
231
+ return {};
232
+ }
233
+
234
+ if (Array.isArray(headers)) {
235
+ return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
236
+ }
237
+
238
+ if (typeof headers.entries === "function") {
239
+ return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
240
+ }
241
+
242
+ if (isPlainObject(headers)) {
243
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
244
+ }
245
+
246
+ return {};
247
+ }
248
+
249
+ function bodyToBase64(body) {
250
+ if (body === undefined || body === null) {
251
+ return undefined;
252
+ }
253
+ if (typeof body === "string") {
254
+ return nodeBuffer.from(body, "utf8").toString("base64");
255
+ }
256
+ if (body instanceof URLSearchParams) {
257
+ return nodeBuffer.from(body.toString(), "utf8").toString("base64");
258
+ }
259
+ if (body instanceof ArrayBuffer) {
260
+ return nodeBuffer.from(body).toString("base64");
261
+ }
262
+ if (ArrayBuffer.isView(body)) {
263
+ return nodeBuffer.from(body.buffer, body.byteOffset, body.byteLength).toString("base64");
264
+ }
265
+ throw new Error("Unsupported source fetch request body.");
266
+ }
267
+
268
+ function normalizeFetchInput(input, init = {}) {
269
+ const url = typeof input === "string" || input instanceof URL ? String(input) : input?.url;
270
+ if (!url) {
271
+ throw new Error("Source fetch requires a URL.");
272
+ }
273
+
274
+ return {
275
+ bodyBase64: bodyToBase64(init.body),
276
+ headers: headersToObject(init.headers ?? input?.headers),
277
+ method: init.method ?? input?.method ?? "GET",
278
+ url
279
+ };
280
+ }
281
+
282
+ function createBrokeredResponse(response) {
283
+ const body = nodeBuffer.from(response.bodyBase64 ?? "", "base64");
284
+ const headers = new Map(Object.entries(response.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)]));
285
+ return {
286
+ headers: {
287
+ entries() {
288
+ return headers.entries();
289
+ },
290
+ get(name) {
291
+ return headers.get(String(name).toLowerCase()) ?? null;
292
+ },
293
+ has(name) {
294
+ return headers.has(String(name).toLowerCase());
295
+ }
296
+ },
297
+ ok: Boolean(response.ok),
298
+ redirected: false,
299
+ status: response.status,
300
+ statusText: response.statusText ?? "",
301
+ url: response.url ?? "",
302
+ async arrayBuffer() {
303
+ return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
304
+ },
305
+ async json() {
306
+ return JSON.parse(body.toString("utf8"));
307
+ },
308
+ async text() {
309
+ return body.toString("utf8");
310
+ }
311
+ };
312
+ }
313
+
314
+ function brokeredFetch(input, init = {}) {
315
+ if (!sendToParent) {
316
+ return Promise.reject(new Error("Source fetch broker is not available."));
317
+ }
318
+
319
+ const id = nextFetchId;
320
+ nextFetchId += 1;
321
+ const request = normalizeFetchInput(input, init);
322
+ return new Promise((resolve, reject) => {
323
+ pendingFetches.set(id, { reject, resolve });
324
+ sendToParent({ id, request, type: "source-runtime.fetch" });
325
+ }).then(createBrokeredResponse);
326
+ }
327
+
328
+ function installRestrictedGlobals() {
329
+ for (const key of Object.keys(nodeProcess.env)) {
330
+ delete nodeProcess.env[key];
331
+ }
332
+
333
+ const restrictedProcess = Object.freeze({
334
+ argv: [],
335
+ env: Object.freeze({}),
336
+ platform: nodeProcess.platform,
337
+ versions: Object.freeze({ node: nodeProcess.versions.node })
338
+ });
339
+ const globals = {
340
+ Buffer: undefined,
341
+ Function: undefined,
342
+ clearImmediate: undefined,
343
+ clearInterval: undefined,
344
+ clearTimeout: undefined,
345
+ eval: undefined,
346
+ fetch: brokeredFetch,
347
+ process: restrictedProcess,
348
+ setImmediate: undefined,
349
+ setInterval: undefined,
350
+ setTimeout: undefined
351
+ };
352
+
353
+ for (const [key, value] of Object.entries(globals)) {
354
+ try {
355
+ Object.defineProperty(globalThis, key, {
356
+ configurable: false,
357
+ value,
358
+ writable: false
359
+ });
360
+ } catch (error) {
361
+ throw new Error(`Failed to harden global "${key}": ${error instanceof Error ? error.message : String(error)}`);
362
+ }
363
+ }
364
+ }
365
+
366
+ async function loadSourceApp(artifact) {
367
+ const source = artifact.server?.source;
368
+ if (!source) {
369
+ throw new Error("Artifact does not include a source bundle.");
370
+ }
371
+
372
+ const module = await import(`data:text/javascript;base64,${source.bundle}`);
373
+ return module.default;
374
+ }
375
+
376
+ function jsonSafe(value) {
377
+ return cloneJson(value ?? null);
378
+ }
379
+
380
+ function sendResult(payload) {
381
+ sendToParent?.({ ok: true, type: "source-runtime.result", ...payload });
382
+ }
383
+
384
+ function sendError(error) {
385
+ sendToParent?.({
386
+ error: {
387
+ message: error instanceof Error ? error.message : String(error),
388
+ name: error instanceof Error ? error.name : "Error"
389
+ },
390
+ ok: false,
391
+ type: "source-runtime.result"
392
+ });
393
+ }
394
+
395
+ async function runSource(request) {
396
+ const app = await loadSourceApp(request.artifact);
397
+ const source = createSourceContext({
398
+ artifact: request.artifact,
399
+ auth: request.auth,
400
+ env: request.env ?? {},
401
+ rows: request.rows ?? {}
402
+ });
403
+
404
+ if (request.op === "query") {
405
+ const handler = app.queries?.[request.name];
406
+ if (!handler) {
407
+ throw new Error(`Unknown query: ${request.name}`);
408
+ }
409
+ const result = await handler(source.ctx, ...(request.args ?? []));
410
+ sendResult({ result: jsonSafe(result) });
411
+ return;
412
+ }
413
+
414
+ if (request.op === "mutation") {
415
+ const handler = app.mutations?.[request.name];
416
+ if (!handler) {
417
+ throw new Error(`Unknown mutation: ${request.name}`);
418
+ }
419
+ const result = await handler(source.ctx, ...(request.args ?? []));
420
+ sendResult({
421
+ operations: jsonSafe(source.operations),
422
+ result: jsonSafe(result)
423
+ });
424
+ return;
425
+ }
426
+
427
+ throw new Error(`Unsupported source operation: ${request.op}`);
428
+ }
429
+
430
+ installRestrictedGlobals();
431
+
432
+ nodeProcess.on("message", (message) => {
433
+ if (!isPlainObject(message)) {
434
+ return;
435
+ }
436
+
437
+ if (message.type === "source-runtime.fetch.result") {
438
+ const pending = pendingFetches.get(message.id);
439
+ if (!pending) {
440
+ return;
441
+ }
442
+ pendingFetches.delete(message.id);
443
+ if (message.ok) {
444
+ pending.resolve(message.response);
445
+ } else {
446
+ pending.reject(new Error(message.error?.message ?? "Source fetch failed."));
447
+ }
448
+ return;
449
+ }
450
+
451
+ if (message.type === "source-runtime.run") {
452
+ runSource(message.request).catch(sendError);
453
+ }
454
+ });