@lovrozagar/oat 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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/bin/oat.js +2 -0
  4. package/dist/cli.js +386 -0
  5. package/dist/config/define-config.d.ts +165 -0
  6. package/dist/config/define-config.d.ts.map +1 -0
  7. package/dist/config/define-config.js +24 -0
  8. package/dist/config/define-config.js.map +1 -0
  9. package/dist/config/load.d.ts +10 -0
  10. package/dist/config/load.d.ts.map +1 -0
  11. package/dist/config/load.js +61 -0
  12. package/dist/config/load.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.js +9 -0
  15. package/dist/reference/defects.d.ts +254 -0
  16. package/dist/reference/defects.d.ts.map +1 -0
  17. package/dist/reference/defects.js +265 -0
  18. package/dist/reference/defects.js.map +1 -0
  19. package/dist/reference/dialect.d.ts +104 -0
  20. package/dist/reference/dialect.d.ts.map +1 -0
  21. package/dist/reference/dialect.js +174 -0
  22. package/dist/reference/dialect.js.map +1 -0
  23. package/dist/reference/http.d.ts +79 -0
  24. package/dist/reference/http.d.ts.map +1 -0
  25. package/dist/reference/http.js +889 -0
  26. package/dist/reference/http.js.map +1 -0
  27. package/dist/reference/model.d.ts +56 -0
  28. package/dist/reference/model.d.ts.map +1 -0
  29. package/dist/reference/model.js +156 -0
  30. package/dist/reference/model.js.map +1 -0
  31. package/dist/reference/query.d.ts +45 -0
  32. package/dist/reference/query.d.ts.map +1 -0
  33. package/dist/reference/query.js +353 -0
  34. package/dist/reference/query.js.map +1 -0
  35. package/dist/reference/spec.d.ts +13 -0
  36. package/dist/reference/spec.d.ts.map +1 -0
  37. package/dist/reference/spec.js +547 -0
  38. package/dist/reference/spec.js.map +1 -0
  39. package/dist/reference/store-api.d.ts +59 -0
  40. package/dist/reference/store-api.d.ts.map +1 -0
  41. package/dist/reference/store-api.js +76 -0
  42. package/dist/reference/store-api.js.map +1 -0
  43. package/dist/reference/stores/memory.d.ts +27 -0
  44. package/dist/reference/stores/memory.d.ts.map +1 -0
  45. package/dist/reference/stores/memory.js +97 -0
  46. package/dist/reference/stores/memory.js.map +1 -0
  47. package/dist/reference/stores/postgres.d.ts +61 -0
  48. package/dist/reference/stores/postgres.d.ts.map +1 -0
  49. package/dist/reference/stores/postgres.js +406 -0
  50. package/dist/reference/stores/postgres.js.map +1 -0
  51. package/dist/reference/stores/sqlite-driver.d.ts +54 -0
  52. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
  53. package/dist/reference/stores/sqlite-driver.js +99 -0
  54. package/dist/reference/stores/sqlite-driver.js.map +1 -0
  55. package/dist/reference/stores/sqlite.d.ts +68 -0
  56. package/dist/reference/stores/sqlite.d.ts.map +1 -0
  57. package/dist/reference/stores/sqlite.js +464 -0
  58. package/dist/reference/stores/sqlite.js.map +1 -0
  59. package/dist/report/console.d.ts +33 -0
  60. package/dist/report/console.d.ts.map +1 -0
  61. package/dist/report/console.js +237 -0
  62. package/dist/report/console.js.map +1 -0
  63. package/dist/report/matrix.d.ts +131 -0
  64. package/dist/report/matrix.d.ts.map +1 -0
  65. package/dist/report/matrix.js +865 -0
  66. package/dist/report/matrix.js.map +1 -0
  67. package/dist/report/render.d.ts +66 -0
  68. package/dist/report/render.d.ts.map +1 -0
  69. package/dist/report/render.js +423 -0
  70. package/dist/report/render.js.map +1 -0
  71. package/dist/runtime/async.d.ts +34 -0
  72. package/dist/runtime/async.d.ts.map +1 -0
  73. package/dist/runtime/async.js +130 -0
  74. package/dist/runtime/async.js.map +1 -0
  75. package/dist/runtime/auth.d.ts +46 -0
  76. package/dist/runtime/auth.d.ts.map +1 -0
  77. package/dist/runtime/auth.js +236 -0
  78. package/dist/runtime/auth.js.map +1 -0
  79. package/dist/runtime/checks.d.ts +89 -0
  80. package/dist/runtime/checks.d.ts.map +1 -0
  81. package/dist/runtime/checks.js +3400 -0
  82. package/dist/runtime/checks.js.map +1 -0
  83. package/dist/runtime/client.d.ts +65 -0
  84. package/dist/runtime/client.d.ts.map +1 -0
  85. package/dist/runtime/client.js +122 -0
  86. package/dist/runtime/client.js.map +1 -0
  87. package/dist/runtime/finding.d.ts +50 -0
  88. package/dist/runtime/finding.d.ts.map +1 -0
  89. package/dist/runtime/finding.js +50 -0
  90. package/dist/runtime/finding.js.map +1 -0
  91. package/dist/runtime/fixture.d.ts +20 -0
  92. package/dist/runtime/fixture.d.ts.map +1 -0
  93. package/dist/runtime/fixture.js +175 -0
  94. package/dist/runtime/fixture.js.map +1 -0
  95. package/dist/runtime/progress.d.ts +41 -0
  96. package/dist/runtime/progress.d.ts.map +1 -0
  97. package/dist/runtime/progress.js +186 -0
  98. package/dist/runtime/progress.js.map +1 -0
  99. package/dist/runtime/run.d.ts +69 -0
  100. package/dist/runtime/run.d.ts.map +1 -0
  101. package/dist/runtime/run.js +575 -0
  102. package/dist/runtime/run.js.map +1 -0
  103. package/dist/runtime/teardown.d.ts +38 -0
  104. package/dist/runtime/teardown.d.ts.map +1 -0
  105. package/dist/runtime/teardown.js +129 -0
  106. package/dist/runtime/teardown.js.map +1 -0
  107. package/dist/runtime/validate.d.ts +31 -0
  108. package/dist/runtime/validate.d.ts.map +1 -0
  109. package/dist/runtime/validate.js +111 -0
  110. package/dist/runtime/validate.js.map +1 -0
  111. package/dist/runtime/world.d.ts +49 -0
  112. package/dist/runtime/world.d.ts.map +1 -0
  113. package/dist/runtime/world.js +119 -0
  114. package/dist/runtime/world.js.map +1 -0
  115. package/dist/spec/collection.d.ts +31 -0
  116. package/dist/spec/collection.d.ts.map +1 -0
  117. package/dist/spec/collection.js +121 -0
  118. package/dist/spec/collection.js.map +1 -0
  119. package/dist/spec/conventions.d.ts +103 -0
  120. package/dist/spec/conventions.d.ts.map +1 -0
  121. package/dist/spec/conventions.js +275 -0
  122. package/dist/spec/conventions.js.map +1 -0
  123. package/dist/spec/extensions.d.ts +74 -0
  124. package/dist/spec/extensions.d.ts.map +1 -0
  125. package/dist/spec/extensions.js +335 -0
  126. package/dist/spec/extensions.js.map +1 -0
  127. package/dist/spec/graph.d.ts +89 -0
  128. package/dist/spec/graph.d.ts.map +1 -0
  129. package/dist/spec/graph.js +318 -0
  130. package/dist/spec/graph.js.map +1 -0
  131. package/dist/spec/load.d.ts +36 -0
  132. package/dist/spec/load.d.ts.map +1 -0
  133. package/dist/spec/load.js +166 -0
  134. package/dist/spec/load.js.map +1 -0
  135. package/dist/spec/types.d.ts +57 -0
  136. package/dist/spec/types.d.ts.map +1 -0
  137. package/dist/spec/types.js +24 -0
  138. package/dist/spec/types.js.map +1 -0
  139. package/labs/README.md +130 -0
  140. package/labs/annotated-openapi.yaml +311 -0
  141. package/labs/anyrow.config.ts +107 -0
  142. package/labs/demo.mjs +124 -0
  143. package/labs/local.config.ts +41 -0
  144. package/labs/minimal.config.ts +32 -0
  145. package/package.json +80 -0
@@ -0,0 +1,889 @@
1
+ /**
2
+ * The reference backend's HTTP layer — one implementation, any store.
3
+ *
4
+ * Routing, auth, tenancy, validation and the defect behaviours that live above storage are
5
+ * written once here. Each engine supplies only a `Store`, so a defect is defined in one place
6
+ * and exercised identically on every backend; when two engines then disagree about what that
7
+ * defect *does*, the disagreement is real rather than an artefact of two implementations
8
+ * drifting apart.
9
+ *
10
+ * Backends: `createMemoryServer` (no dependencies), `createSqliteServer` (`node:sqlite`),
11
+ * `createD1Server` (Cloudflare D1 over HTTP),
12
+ * `createPostgresServer` (a reachable server). Every store is imported lazily — a static import
13
+ * would load one engine's driver for all of them, and an optional runtime such as `node:sqlite`
14
+ * would take down paths that never touch it.
15
+ *
16
+ * This is a test fixture. It is excluded from the published package; oat itself never talks to
17
+ * a database.
18
+ */
19
+ import { createServer } from "node:http";
20
+ import { DefectSet as Defects } from "./defects.js";
21
+ import { ENTITIES, JOB, TABLE, fieldsWhere, writableFields } from "./model.js";
22
+ import { DIALECTS, POSTGREST, toCanonicalFilter } from "./dialect.js";
23
+ import { buildSpec, buildUntaggedSpec } from "./spec.js";
24
+ import { SqlError } from "./store-api.js";
25
+ const PRINCIPALS = [
26
+ { key: "key_alpha", projectId: "proj_alpha", rank: 2, token: "tok_alpha" },
27
+ { key: "key_beta", projectId: "proj_beta", rank: 2, token: "tok_beta" },
28
+ { key: "key_alpha_member", projectId: "proj_alpha", rank: 1, token: "tok_alpha_member" },
29
+ { key: "key_alpha_viewer", projectId: "proj_alpha", rank: 0, token: "tok_alpha_viewer" },
30
+ ];
31
+ const TENANT_FIELD = "project_id";
32
+ class HttpError extends Error {
33
+ status;
34
+ errorKey;
35
+ constructor(status, errorKey, message) {
36
+ super(message);
37
+ this.status = status;
38
+ this.errorKey = errorKey;
39
+ }
40
+ }
41
+ /* ------------------------------------------------------------------ validation */
42
+ function validateBody(entity, body, phase, defects) {
43
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
44
+ throw new HttpError(400, "invalid_input", "request body must be a JSON object");
45
+ }
46
+ const input = body;
47
+ const allowed = new Map(writableFields(entity, phase).map((f) => [f.name, f]));
48
+ for (const key of Object.keys(input)) {
49
+ if (allowed.has(key))
50
+ continue;
51
+ const known = entity.fields.find((f) => f.name === key);
52
+ if (known !== undefined && (known.immutable === true || known.generated === true)) {
53
+ if (defects.has("IMMUTABLE_WRITABLE")) {
54
+ allowed.set(key, known);
55
+ continue;
56
+ }
57
+ throw new HttpError(400, "invalid_input", `field "${key}" is not writable`);
58
+ }
59
+ throw new HttpError(400, "invalid_input", `unknown field "${key}"`);
60
+ }
61
+ if (phase === "create") {
62
+ for (const field of allowed.values()) {
63
+ if (field.required === true &&
64
+ input[field.name] === undefined &&
65
+ !defects.has("REQUIRED_NOT_VALIDATED")) {
66
+ throw new HttpError(400, "invalid_input", `field "${field.name}" is required`);
67
+ }
68
+ }
69
+ }
70
+ const out = {};
71
+ for (const [key, value] of Object.entries(input)) {
72
+ const field = allowed.get(key);
73
+ if (field === undefined)
74
+ continue;
75
+ out[key] = coerceField(field, value, defects);
76
+ }
77
+ return out;
78
+ }
79
+ function coerceField(field, value, defects) {
80
+ if (value === null) {
81
+ if (field.nullable !== true) {
82
+ throw new HttpError(400, "invalid_input", `field "${field.name}" is not nullable`);
83
+ }
84
+ return null;
85
+ }
86
+ if (field.type === "integer" || field.type === "number") {
87
+ if (typeof value !== "number") {
88
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a number`);
89
+ }
90
+ if (field.type === "integer" && !Number.isInteger(value)) {
91
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be an integer`);
92
+ }
93
+ return value;
94
+ }
95
+ if (field.type === "boolean") {
96
+ if (typeof value !== "boolean") {
97
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a boolean`);
98
+ }
99
+ return value;
100
+ }
101
+ if (typeof value !== "string") {
102
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a string`);
103
+ }
104
+ if (field.maxLength !== undefined &&
105
+ value.length > field.maxLength &&
106
+ !defects.has("MAXLENGTH_NOT_VALIDATED")) {
107
+ throw new HttpError(400, "invalid_input", `field "${field.name}" exceeds maxLength`);
108
+ }
109
+ if (field.enum !== undefined && !field.enum.includes(value) && !defects.has("ENUM_NOT_VALIDATED")) {
110
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be one of ${field.enum.join(", ")}`);
111
+ }
112
+ return value;
113
+ }
114
+ function matchRoute(pathname) {
115
+ const segments = pathname.split("/").filter(Boolean);
116
+ for (const entity of ENTITIES) {
117
+ for (const [template, isItem] of [
118
+ [entity.itemPath, true],
119
+ [entity.collectionPath, false],
120
+ ]) {
121
+ const parts = template.split("/").filter(Boolean);
122
+ if (parts.length !== segments.length)
123
+ continue;
124
+ const scope = {};
125
+ let ok = true;
126
+ for (let i = 0; i < parts.length; i++) {
127
+ const part = parts[i];
128
+ const value = segments[i];
129
+ if (part === undefined || value === undefined) {
130
+ ok = false;
131
+ break;
132
+ }
133
+ if (part.startsWith("{"))
134
+ scope[part.slice(1, -1)] = value;
135
+ else if (part !== value) {
136
+ ok = false;
137
+ break;
138
+ }
139
+ }
140
+ if (!ok)
141
+ continue;
142
+ return { entity, itemId: isItem ? (scope[entity.itemParam] ?? null) : null, scope };
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ /**
148
+ * One server, any store. Defining a defect once and exercising it on every engine is what makes
149
+ * a disagreement between engines meaningful rather than an artefact of two implementations.
150
+ */
151
+ export async function createReferenceServer(options,
152
+ /* Required, and every caller supplies it by dynamic import: a static import of a storage
153
+ * module would load that engine's driver for *every* backend, and a missing optional
154
+ * runtime (node:sqlite behind a flag) would then take down paths that never touch it. */
155
+ createStore) {
156
+ const defects = new Defects(options.defects ?? []);
157
+ const dialect = DIALECTS[options.dialect ?? "postgrest"] ?? POSTGREST;
158
+ const store = await createStore(defects);
159
+ const jobStartedAt = new Map();
160
+ /* Snapshot of a collection taken before a write, replayed while STALE_LIST is on. */
161
+ const staleSnapshot = new Map();
162
+ /* Idempotency-Key → the record the first request created, so a replay returns it. */
163
+ const idempotent = new Map();
164
+ function authenticate(req) {
165
+ const header = req.headers.authorization;
166
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) {
167
+ throw new HttpError(401, "unauthorized", "missing bearer credential");
168
+ }
169
+ const principal = PRINCIPALS.find((p) => p.token === header.slice(7).trim());
170
+ if (principal === undefined)
171
+ throw new HttpError(401, "unauthorized", "unrecognised credential");
172
+ return principal;
173
+ }
174
+ function assertWrite(principal, kind) {
175
+ /* Viewer cannot write; member cannot delete; owner can do everything. */
176
+ if (kind === "delete" && principal.rank < 2) {
177
+ throw new HttpError(403, "forbidden", "role cannot delete");
178
+ }
179
+ if ((kind === "create" || kind === "update") && principal.rank < 1) {
180
+ throw new HttpError(403, "forbidden", "role cannot write");
181
+ }
182
+ }
183
+ function assertTenant(principal, scope) {
184
+ const projectId = scope.project_id;
185
+ if (projectId !== undefined && projectId !== principal.projectId) {
186
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
187
+ }
188
+ }
189
+ /** Walks a record up its parent chain to the owning tenant, driven by the descriptors. */
190
+ async function ownedByTenant(entity, record, principal) {
191
+ if (entity.fields.some((f) => f.name === TENANT_FIELD)) {
192
+ return record[TENANT_FIELD] === principal.projectId;
193
+ }
194
+ for (const parentParam of entity.parents) {
195
+ const parentEntity = ENTITIES.find((e) => e.itemParam === parentParam);
196
+ if (parentEntity === undefined)
197
+ continue;
198
+ const link = record[parentParam];
199
+ if (typeof link !== "string")
200
+ continue;
201
+ const parent = await store.byId(parentEntity, link);
202
+ if (parent === null)
203
+ return false;
204
+ return ownedByTenant(parentEntity, parent, principal);
205
+ }
206
+ return false;
207
+ }
208
+ function projectJob(job) {
209
+ const startedAt = jobStartedAt.get(String(job.id));
210
+ if (startedAt === undefined || defects.has("ASYNC_NEVER_COMPLETES"))
211
+ return job;
212
+ const ratio = Math.min((Date.now() - startedAt) / 60, 1);
213
+ const progress = Math.floor(ratio * 100);
214
+ return { ...job, progress, status: ratio >= 1 ? "complete" : progress > 0 ? "running" : "pending" };
215
+ }
216
+ /**
217
+ * Rewrites a field in the *collection* projection only.
218
+ *
219
+ * Applied where the listing is built, never on the item route, so the two disagree exactly as
220
+ * a stale denormalised listing would. The value is plausible rather than obviously wrong: a
221
+ * projection that returned garbage would be caught by schema validation instead.
222
+ */
223
+ function skewForList(record) {
224
+ if (!defects.has("LIST_DETAIL_DISAGREE"))
225
+ return record;
226
+ if (typeof record.name !== "string")
227
+ return record;
228
+ return { ...record, name: `${record.name} (listing)` };
229
+ }
230
+ function decorate(record) {
231
+ if (!defects.has("RESPONSE_SCHEMA_DRIFT"))
232
+ return record;
233
+ return { ...record, _internal_revision: 7, _shard: "shard-a" };
234
+ }
235
+ function withDefaults(entity, input, scope) {
236
+ const record = { ...input };
237
+ record[entity.identity] = store.nextId(entity.name);
238
+ for (const parent of entity.parents) {
239
+ if (entity.fields.some((f) => f.name === parent))
240
+ record[parent] = scope[parent] ?? null;
241
+ }
242
+ record.created_at = store.now();
243
+ record.updated_at = store.now();
244
+ if (entity.softDeleteField !== undefined)
245
+ record[entity.softDeleteField] = null;
246
+ for (const field of entity.fields) {
247
+ if (record[field.name] !== undefined)
248
+ continue;
249
+ record[field.name] =
250
+ field.nullable === true
251
+ ? null
252
+ : field.type === "boolean"
253
+ ? false
254
+ : field.type === "integer" || field.type === "number"
255
+ ? 0
256
+ : (field.enum?.[0] ?? "");
257
+ }
258
+ return record;
259
+ }
260
+ function snapshotKey(entity, scope) {
261
+ return `${entity.name}:${scope.project_id ?? ""}:${scope.table_id ?? ""}`;
262
+ }
263
+ /**
264
+ * Keeps a parent's derived count current after a child write.
265
+ *
266
+ * This is the behaviour `x-invalidate` promises: a write here changes what a *different*
267
+ * route serves. Under the defect the promise is published and not kept, which is the common
268
+ * real failure — a denormalised counter or a cached projection that nobody refreshes.
269
+ */
270
+ async function refreshParentCount(entity, scope, principal) {
271
+ if (entity.name !== "row")
272
+ return;
273
+ if (defects.has("PARENT_PROJECTION_STALE"))
274
+ return;
275
+ const table = ENTITIES.find((candidate) => candidate.name === "table");
276
+ const tableId = scope.table_id;
277
+ if (table === undefined || tableId === undefined)
278
+ return;
279
+ /*
280
+ * Paged, because the store caps a page at the entity's declared maxLimit — `row` allows 5 —
281
+ * so a single large-limit query silently counts one page rather than the collection.
282
+ *
283
+ * The envelope's own total is deliberately not used either: a defect that corrupts the
284
+ * reported count would propagate into this derived value and make the parent look stale
285
+ * for a reason that has nothing to do with invalidation.
286
+ */
287
+ const pageSize = entity.maxLimit;
288
+ let total = 0;
289
+ for (let page = 1; page <= 200; page++) {
290
+ const chunk = await store.query(entity, { ...scope, project_id: principal.projectId }, { limit: pageSize, page }, { softDeleteField: entity.softDeleteField });
291
+ total += chunk.items.length;
292
+ if (chunk.items.length < pageSize)
293
+ break;
294
+ }
295
+ await store.update(table, tableId, { row_count: total });
296
+ }
297
+ const grants = new Map();
298
+ function canReadViaGrant(entityName, id, principal) {
299
+ if (defects.has("INVITE_NEVER_GRANTS"))
300
+ return false;
301
+ for (const grant of grants.values()) {
302
+ if (grant.entity !== entityName || grant.resourceId !== id)
303
+ continue;
304
+ if (grant.granteeKey !== principal.key)
305
+ continue;
306
+ if (grant.accepted)
307
+ return true;
308
+ }
309
+ return false;
310
+ }
311
+ async function findItem(entity, principal, id) {
312
+ const record = await store.byId(entity, id);
313
+ if (record === null)
314
+ throw new HttpError(404, "not_found", `${entity.name} ${id} does not exist`);
315
+ const owned = await ownedByTenant(entity, record, principal);
316
+ if (!defects.has("CROSS_TENANT_READ") && !owned && !canReadViaGrant(entity.name, id, principal)) {
317
+ /* Correct is 404: the same answer an id that never existed would get. Answering 403
318
+ * here is the defect — the denial is right, but the *status* confirms the record is
319
+ * real, which is all an attacker enumerating identifiers needs. */
320
+ throw defects.has("EXISTENCE_LEAK_VIA_STATUS")
321
+ ? new HttpError(403, "forbidden", `${entity.name} ${id} belongs to another tenant`)
322
+ : new HttpError(404, "not_found", `${entity.name} ${id} does not exist`);
323
+ }
324
+ if (entity.softDeleteField !== undefined && record[entity.softDeleteField] !== null) {
325
+ throw new HttpError(404, "not_found", `${entity.name} ${id} has been deleted`);
326
+ }
327
+ return record;
328
+ }
329
+ async function dispatch(req, res) {
330
+ const url = new URL(req.url ?? "/", "http://localhost");
331
+ const method = (req.method ?? "GET").toUpperCase();
332
+ if (url.pathname === "/v1/openapi/spec") {
333
+ return send(res, 200, options.untagged === true ? buildUntaggedSpec(dialect) : buildSpec(dialect));
334
+ }
335
+ if (url.pathname === "/v1/auth/token" && method === "POST") {
336
+ const body = await readJson(req);
337
+ const principal = PRINCIPALS.find((p) => p.key === body.key);
338
+ if (principal === undefined)
339
+ throw new HttpError(401, "unauthorized", "unknown API key");
340
+ return send(res, 200, {
341
+ access_token: principal.token,
342
+ expires_in: 3600,
343
+ project_id: principal.projectId,
344
+ });
345
+ }
346
+ if (url.pathname.endsWith("/jobs/start") && method === "POST") {
347
+ const principal = authenticate(req);
348
+ const projectId = url.pathname.split("/")[3] ?? "";
349
+ if (projectId !== principal.projectId) {
350
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
351
+ }
352
+ requireJson(req, defects);
353
+ const name = (await readJson(req)).name;
354
+ if (typeof name !== "string" || name === "") {
355
+ throw new HttpError(400, "invalid_input", 'field "name" is required');
356
+ }
357
+ const job = withDefaults(JOB, { name }, { project_id: projectId });
358
+ job.status = "pending";
359
+ job.progress = 0;
360
+ if (!defects.has("EFFECT_NOT_APPLIED")) {
361
+ await store.insert(JOB, job);
362
+ jobStartedAt.set(String(job.id), Date.now());
363
+ }
364
+ return send(res, 202, defects.has("ASYNC_RECEIPT_MISSING_ID") ? { accepted: true } : { accepted: true, job_id: job.id });
365
+ }
366
+ const inviteMatch = /^\/v1\/projects\/([^/]+)\/tables\/([^/]+)\/invites$/.exec(url.pathname);
367
+ if (inviteMatch !== null && method === "POST") {
368
+ const principal = authenticate(req);
369
+ const projectId = inviteMatch[1] ?? "";
370
+ const tableId = inviteMatch[2] ?? "";
371
+ if (projectId !== principal.projectId) {
372
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
373
+ }
374
+ assertWrite(principal, "create");
375
+ requireJson(req, defects);
376
+ const key = (await readJson(req)).key;
377
+ if (typeof key !== "string" || key === "") {
378
+ throw new HttpError(400, "invalid_input", 'field "key" is required');
379
+ }
380
+ if (PRINCIPALS.find((p) => p.key === key) === undefined) {
381
+ throw new HttpError(400, "invalid_input", "unknown invitee key");
382
+ }
383
+ await findItem(TABLE, principal, tableId);
384
+ const grant = {
385
+ accepted: false,
386
+ entity: "table",
387
+ grantId: `grn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
388
+ granteeKey: key,
389
+ resourceId: tableId,
390
+ token: `inv_${Math.random().toString(36).slice(2, 12)}`,
391
+ };
392
+ grants.set(grant.grantId, grant);
393
+ return send(res, 201, { grant_id: grant.grantId, token: grant.token });
394
+ }
395
+ const acceptMatch = /^\/v1\/invites\/([^/]+)\/accept$/.exec(url.pathname);
396
+ if (acceptMatch !== null && method === "POST") {
397
+ const principal = authenticate(req);
398
+ const token = acceptMatch[1] ?? "";
399
+ const grant = [...grants.values()].find((item) => item.token === token);
400
+ if (grant === undefined)
401
+ throw new HttpError(404, "not_found", "invite not found");
402
+ if (grant.granteeKey !== principal.key) {
403
+ throw new HttpError(403, "forbidden", "invite is not for this principal");
404
+ }
405
+ grant.accepted = true;
406
+ return send(res, 200, { accepted: true });
407
+ }
408
+ const revokeMatch = /^\/v1\/projects\/([^/]+)\/tables\/([^/]+)\/grants\/([^/]+)$/.exec(url.pathname);
409
+ if (revokeMatch !== null && method === "DELETE") {
410
+ const principal = authenticate(req);
411
+ const projectId = revokeMatch[1] ?? "";
412
+ const tableId = revokeMatch[2] ?? "";
413
+ const grantId = revokeMatch[3] ?? "";
414
+ if (projectId !== principal.projectId) {
415
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
416
+ }
417
+ assertWrite(principal, "delete");
418
+ const grant = grants.get(grantId);
419
+ if (grant === undefined || grant.resourceId !== tableId) {
420
+ throw new HttpError(404, "not_found", "grant not found");
421
+ }
422
+ if (!defects.has("REVOKE_IGNORED"))
423
+ grants.delete(grantId);
424
+ return send(res, 200, { revoked: true });
425
+ }
426
+ const match = matchRoute(url.pathname);
427
+ if (match === null)
428
+ throw new HttpError(404, "not_found", `no route for ${url.pathname}`);
429
+ const principal = authenticate(req);
430
+ const { entity, scope, itemId } = match;
431
+ /* Item GET may be a delegated read — assertTenant would 403 a valid grant before
432
+ * findItem can honour it. Collection GET and mutations stay tenant-bound. */
433
+ if (!(method === "GET" && itemId !== null))
434
+ assertTenant(principal, match.scope);
435
+ if (itemId === null) {
436
+ if (method === "GET") {
437
+ const key = snapshotKey(entity, scope);
438
+ /* Only the default listing is served stale. Freezing filtered queries too would
439
+ * also swallow filter validation, so the defect would masquerade as several
440
+ * unrelated ones. */
441
+ const plainListing = [...url.searchParams.keys()].every((k) => k === dialect.params.limit
442
+ || k === dialect.params.page
443
+ || k === dialect.params.offset);
444
+ if (defects.has("STALE_LIST") && plainListing && staleSnapshot.has(key)) {
445
+ const frozen = staleSnapshot.get(key) ?? [];
446
+ const stale = paginated(dialect, entity, url, {
447
+ count: frozen.length,
448
+ hasMore: false,
449
+ items: frozen,
450
+ limit: entity.defaultLimit,
451
+ nextCursor: null,
452
+ page: 1,
453
+ });
454
+ return send(res, 200, stale.body, stale.headers);
455
+ }
456
+ const number = (name) => {
457
+ const raw = url.searchParams.get(name);
458
+ if (raw === null)
459
+ return undefined;
460
+ const parsed = Number(raw);
461
+ if (!Number.isFinite(parsed)) {
462
+ throw new HttpError(400, "invalid_input", `query parameter "${name}" must be numeric`);
463
+ }
464
+ return parsed;
465
+ };
466
+ const rawFilter = dialect.grammar === "equality"
467
+ ? equalityFilter(url, entity)
468
+ : url.searchParams.get(dialect.params.filter);
469
+ let filter;
470
+ /* Alone, the filter behaves perfectly. Each of these defects drops it the moment
471
+ * another axis joins the request — the combination is the bug, not either axis. */
472
+ const sorted = url.searchParams.get(dialect.params.order);
473
+ const selected = readSelect(url, dialect, entity);
474
+ const searched = url.searchParams.get(dialect.params.search);
475
+ const hasSort = sorted !== null && sorted !== "";
476
+ const hasSelect = selected !== undefined && selected !== "" && selected !== "*";
477
+ const hasSearch = searched !== null && searched !== "";
478
+ const dropFilter = (defects.has("FILTER_DROPPED_WHEN_SORTED") && hasSort)
479
+ || (defects.has("FILTER_DROPPED_WHEN_SELECTED") && hasSelect)
480
+ || (defects.has("FILTER_DROPPED_WHEN_SEARCHED") && hasSearch)
481
+ || (defects.has("FILTER_DROPPED_WHEN_SORTED_AND_SELECTED") && hasSort && hasSelect)
482
+ || (defects.has("FILTER_DROPPED_WHEN_SORTED_AND_SEARCHED") && hasSort && hasSearch)
483
+ || (defects.has("FILTER_DROPPED_WHEN_SEARCHED_AND_SELECTED") && hasSearch && hasSelect);
484
+ if (rawFilter !== null && rawFilter !== "" && !dropFilter) {
485
+ const canonical = toCanonicalFilter(rawFilter, dialect);
486
+ if (canonical === null) {
487
+ /* A filter the dialect cannot parse is bad client input, so 400 — unless the
488
+ * defect that turns parse failures into server errors is active. Routing this
489
+ * path through the same defect matters: on a dialect whose grammar rejects a
490
+ * malformed value here rather than in the store, the store's 500 path is never
491
+ * reached and the defect would be silently inexpressible. */
492
+ throw defects.has("ERROR_500_ON_BAD_FILTER")
493
+ ? new HttpError(500, "internal_error", "filter parser threw")
494
+ : new HttpError(400, "invalid_input", `malformed filter: ${rawFilter}`);
495
+ }
496
+ filter = canonical;
497
+ }
498
+ /*
499
+ * Under the defect the predicate is withheld from the store, so the page window is
500
+ * computed over the unfiltered set; the filter is then applied to whatever that
501
+ * window contained. Modelled here rather than in each store because it is a bug in
502
+ * *ordering of operations*, not in any one engine's query compiler.
503
+ */
504
+ const pageBeforeFilter = defects.has("FILTER_AFTER_PAGINATION") && filter !== undefined;
505
+ const result = await store.query(entity, { ...scope, project_id: principal.projectId }, {
506
+ cursor: dialect.params.cursor === undefined
507
+ ? undefined
508
+ : (url.searchParams.get(dialect.params.cursor) ?? undefined),
509
+ filter: pageBeforeFilter ? undefined : filter,
510
+ limit: number(dialect.params.limit),
511
+ order: toCanonicalOrder(url.searchParams.get(dialect.params.order) ?? undefined, dialect),
512
+ /* Whichever the dialect publishes. The store pages by number, so an offset is
513
+ * converted using the page size actually in force — the same arithmetic the
514
+ * caller performed to produce the offset. */
515
+ page: pageFrom(url, dialect, number(dialect.params.limit) ?? entity.defaultLimit),
516
+ q: searched ?? undefined,
517
+ select: selected,
518
+ }, {
519
+ softDeleteField: entity.softDeleteField,
520
+ /* Applied to the collection only — the item route never passes through here —
521
+ * so a skewed field makes the two projections disagree exactly as a stale
522
+ * denormalised listing does. */
523
+ transform: (row) => skewForList(entity.name === "job" ? projectJob(row) : row),
524
+ });
525
+ if (pageBeforeFilter && filter !== undefined) {
526
+ const matching = await store.query(entity, { ...scope, project_id: principal.projectId }, { filter, limit: 1000 }, { softDeleteField: entity.softDeleteField });
527
+ const allowed = new Set(matching.items.map((row) => String(row[entity.identity])));
528
+ result.items = result.items.filter((row) => allowed.has(String(row[entity.identity])));
529
+ }
530
+ const listing = paginated(dialect, entity, url, result);
531
+ return send(res, 200, listing.body, listing.headers);
532
+ }
533
+ if (method === "POST") {
534
+ assertWrite(principal, "create");
535
+ requireJson(req, defects);
536
+ /* Scoped by principal as well as key: two tenants using the same key must not be
537
+ * able to read each other's result back. */
538
+ const idempotencyKey = req.headers["idempotency-key"];
539
+ const replayKey = typeof idempotencyKey === "string" && idempotencyKey !== ""
540
+ ? `${principal.projectId}:${entity.name}:${idempotencyKey}`
541
+ : null;
542
+ if (replayKey !== null && !defects.has("IDEMPOTENCY_IGNORED")) {
543
+ const previous = idempotent.get(replayKey);
544
+ if (previous !== undefined) {
545
+ return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, decorate(previous));
546
+ }
547
+ }
548
+ const input = validateBody(entity, await readJson(req), "create", defects);
549
+ if (defects.has("STALE_LIST")) {
550
+ const key = snapshotKey(entity, scope);
551
+ if (!staleSnapshot.has(key)) {
552
+ staleSnapshot.set(key, (await store.query(entity, { ...scope, project_id: principal.projectId }, { limit: 1000 }, { softDeleteField: entity.softDeleteField })).items);
553
+ }
554
+ }
555
+ if (defects.has("CREATE_DROPS_FIELD"))
556
+ delete input.description;
557
+ const record = withDefaults(entity, input, { ...scope, project_id: principal.projectId });
558
+ const created = await store.insert(entity, record);
559
+ if (entity.name === "job")
560
+ jobStartedAt.set(String(created.id), Date.now());
561
+ await refreshParentCount(entity, scope, principal);
562
+ if (replayKey !== null)
563
+ idempotent.set(replayKey, created);
564
+ return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, decorate(created));
565
+ }
566
+ throw new HttpError(404, "not_found", `method ${method} not supported here`);
567
+ }
568
+ if (method === "GET") {
569
+ /* Invert only the middle rank: viewer still reads, owner still reads, member does not.
570
+ * Denying the owner would collapse the rest of the suite. */
571
+ if (defects.has("ROLE_MONOTONICITY_BROKEN") && principal.rank === 1) {
572
+ throw new HttpError(403, "forbidden", "role cannot read this record");
573
+ }
574
+ const record = await findItem(entity, principal, itemId);
575
+ return send(res, 200, decorate(entity.name === "job" ? projectJob(record) : record));
576
+ }
577
+ if (method === "PATCH") {
578
+ assertWrite(principal, "update");
579
+ requireJson(req, defects);
580
+ const existing = await findItem(entity, principal, itemId);
581
+ const patch = validateBody(entity, await readJson(req), "update", defects);
582
+ /*
583
+ * Write only the fields the caller named.
584
+ *
585
+ * Merging the existing record and writing every column back is the `save(entity)`
586
+ * pattern, and it loses concurrent writes to *other* fields: whichever request
587
+ * commits second reinstates the values it read before the first had committed. The
588
+ * merged record is still built, but only to shape the response.
589
+ */
590
+ const changes = defects.has("PATCH_REPLACES")
591
+ ? {
592
+ ...withDefaults(entity, {}, { ...scope, project_id: principal.projectId }),
593
+ ...patch,
594
+ }
595
+ : { ...patch };
596
+ changes.updated_at = store.now();
597
+ delete changes[entity.identity];
598
+ delete changes.created_at;
599
+ if (!defects.has("IMMUTABLE_WRITABLE")) {
600
+ for (const parent of entity.parents)
601
+ delete changes[parent];
602
+ }
603
+ const updated = await store.update(entity, itemId, changes);
604
+ return send(res, 200, decorate(updated ?? { ...existing, ...changes }));
605
+ }
606
+ if (method === "DELETE") {
607
+ assertWrite(principal, "delete");
608
+ const existing = await store.byId(entity, itemId);
609
+ if (existing === null) {
610
+ if (defects.has("DELETE_MISSING_OK"))
611
+ return send(res, 200, { [entity.identity]: itemId });
612
+ throw new HttpError(404, "not_found", `${entity.name} ${itemId} does not exist`);
613
+ }
614
+ const record = await findItem(entity, principal, itemId);
615
+ if (entity.softDeleteField !== undefined) {
616
+ const updated = await store.update(entity, itemId, {
617
+ [entity.softDeleteField]: store.now(),
618
+ updated_at: store.now(),
619
+ });
620
+ return send(res, 200, decorate(updated ?? record));
621
+ }
622
+ await store.remove(entity, itemId);
623
+ return send(res, 200, decorate(record));
624
+ }
625
+ throw new HttpError(404, "not_found", `method ${method} not supported`);
626
+ }
627
+ const server = createServer((req, res) => {
628
+ dispatch(req, res).catch((error) => {
629
+ if (error instanceof HttpError) {
630
+ if (defects.has("ERROR_SCHEMA_DRIFT")) {
631
+ return send(res, error.status, { detail: error.message, error: error.errorKey });
632
+ }
633
+ return send(res, error.status, {
634
+ error_key: error.errorKey,
635
+ message: error.message,
636
+ status: error.status,
637
+ success: false,
638
+ });
639
+ }
640
+ if (error instanceof SqlError) {
641
+ if (defects.has("ERROR_500_ON_BAD_FILTER")) {
642
+ return send(res, 500, {
643
+ error_key: "internal_error",
644
+ message: error.message,
645
+ status: 500,
646
+ success: false,
647
+ });
648
+ }
649
+ return send(res, 400, {
650
+ error_key: "invalid_input",
651
+ message: error.message,
652
+ status: 400,
653
+ success: false,
654
+ });
655
+ }
656
+ send(res, 500, {
657
+ error_key: "internal_error",
658
+ message: error instanceof Error ? error.message : String(error),
659
+ status: 500,
660
+ success: false,
661
+ });
662
+ });
663
+ });
664
+ return new Promise((resolve) => {
665
+ server.listen(0, "127.0.0.1", () => {
666
+ const address = server.address();
667
+ resolve({
668
+ /*
669
+ * `server.close()` only fires once every socket is gone, and oat's client keeps
670
+ * connections alive — so gating teardown on that callback meant the store was
671
+ * never closed and a persistent backend kept every table the run created.
672
+ * Connections are dropped explicitly, and the store is closed either way.
673
+ */
674
+ close: async () => {
675
+ server.closeAllConnections();
676
+ await new Promise((done) => {
677
+ server.close(() => done());
678
+ });
679
+ try {
680
+ await store.close();
681
+ }
682
+ catch {
683
+ /* Teardown must not turn a completed run into a failed one. */
684
+ }
685
+ },
686
+ defects,
687
+ principals: PRINCIPALS,
688
+ server,
689
+ url: `http://127.0.0.1:${address.port}`,
690
+ });
691
+ });
692
+ });
693
+ }
694
+ function send(res, status, body, extraHeaders = {}) {
695
+ const payload = JSON.stringify(body);
696
+ res.writeHead(status, {
697
+ "content-length": Buffer.byteLength(payload),
698
+ "content-type": "application/json",
699
+ ...extraHeaders,
700
+ });
701
+ res.end(payload);
702
+ }
703
+ /**
704
+ * Rewrites a sort expression from the dialect's grammar into the canonical `field.asc` the stores
705
+ * parse, so the storage layer never learns which spelling arrived.
706
+ *
707
+ * Unparseable terms are passed through untouched rather than dropped: the store rejects an
708
+ * unknown sort field with a 400, which is the correct answer to a malformed sort and keeps a bad
709
+ * expression from silently becoming no sort at all.
710
+ */
711
+ function toCanonicalOrder(expression, dialect) {
712
+ if (expression === undefined || expression === "")
713
+ return undefined;
714
+ const grammar = dialect.sortGrammar ?? "dotted";
715
+ if (grammar === "dotted")
716
+ return expression;
717
+ return expression
718
+ .split(",")
719
+ .map((term) => term.trim())
720
+ .filter(Boolean)
721
+ .map((term) => {
722
+ if (grammar === "prefixed") {
723
+ return term.startsWith("-") ? `${term.slice(1)}.desc` : `${term}.asc`;
724
+ }
725
+ const separator = grammar === "colon" ? ":" : " ";
726
+ const index = term.lastIndexOf(separator);
727
+ if (index === -1)
728
+ return `${term}.asc`;
729
+ const field = term.slice(0, index).trim();
730
+ const direction = term.slice(index + 1).trim().toLowerCase();
731
+ return direction === "asc" || direction === "desc" ? `${field}.${direction}` : term;
732
+ })
733
+ .join(",");
734
+ }
735
+ /**
736
+ * Collects one-parameter-per-field equality filters into a canonical expression.
737
+ *
738
+ * Only fields the document declares filterable are honoured; anything else falls through to the
739
+ * unknown-parameter path, so a caller misspelling a field still gets told rather than silently
740
+ * receiving the whole collection.
741
+ */
742
+ function equalityFilter(url, entity) {
743
+ const terms = [];
744
+ for (const field of fieldsWhere(entity, "filterable")) {
745
+ const value = url.searchParams.get(field);
746
+ if (value === null)
747
+ continue;
748
+ terms.push(`${field}.eq.${value}`);
749
+ }
750
+ if (terms.length === 0)
751
+ return null;
752
+ return terms.length === 1 ? terms[0] : `and(${terms.join(",")})`;
753
+ }
754
+ /** Reads the sparse fieldset, whether the resource lives in the parameter name or not. */
755
+ function readSelect(url, dialect, entity) {
756
+ const name = dialect.selectGrammar === "bracketed"
757
+ ? `${dialect.params.select}[${entity.name}]`
758
+ : dialect.params.select;
759
+ return url.searchParams.get(name) ?? undefined;
760
+ }
761
+ /**
762
+ * The page number a request is asking for, whichever way the dialect counts.
763
+ *
764
+ * The store pages by number; an API that counts rows skipped is converted using the page size in
765
+ * force — the same arithmetic the caller did to produce the offset. An offset that is not a whole
766
+ * multiple of the page size has no exact page number, and rounding down matches what every offset
767
+ * API does anyway: return the window starting there.
768
+ */
769
+ function pageFrom(url, dialect, pageSize) {
770
+ const read = (name) => {
771
+ if (name === undefined)
772
+ return undefined;
773
+ const raw = url.searchParams.get(name);
774
+ if (raw === null)
775
+ return undefined;
776
+ const parsed = Number(raw);
777
+ return Number.isFinite(parsed) ? parsed : undefined;
778
+ };
779
+ const page = read(dialect.params.page);
780
+ if (page !== undefined)
781
+ return page;
782
+ const offset = read(dialect.params.offset);
783
+ if (offset === undefined)
784
+ return undefined;
785
+ return Math.floor(offset / Math.max(pageSize, 1)) + 1;
786
+ }
787
+ /**
788
+ * Builds a listing response in whichever pagination model the dialect speaks.
789
+ *
790
+ * Two genuinely different models, not two spellings: an envelope carrying the array alongside a
791
+ * total and a more-pages flag, or a bare array whose pagination facts travel in a `Link` header.
792
+ * Keeping both here means the routing code above never branches on it.
793
+ */
794
+ function paginated(dialect, entity, requestUrl, result) {
795
+ if (dialect.envelope !== null) {
796
+ const envelope = {
797
+ [dialect.envelope.collection ?? entity.plural]: result.items,
798
+ [dialect.envelope.hasMore]: result.hasMore,
799
+ [dialect.envelope.limit]: result.limit,
800
+ [dialect.envelope.page]: result.page,
801
+ [dialect.envelope.total]: result.count,
802
+ };
803
+ if (dialect.envelope.nextCursor !== undefined) {
804
+ envelope[dialect.envelope.nextCursor] = result.nextCursor;
805
+ }
806
+ return { body: envelope, headers: {} };
807
+ }
808
+ /* Root array: the only pagination signal is the header, so a caller learns there is more by
809
+ * being handed the URL that returns it — never by a field. */
810
+ const links = [];
811
+ const offsetParam = dialect.params.offset;
812
+ if (result.hasMore && offsetParam !== undefined) {
813
+ const next = new URL(requestUrl.toString());
814
+ const consumed = Number(next.searchParams.get(offsetParam) ?? 0) + result.items.length;
815
+ next.searchParams.set(offsetParam, String(consumed));
816
+ links.push(`<${next.pathname}${next.search}>; rel="next"`);
817
+ }
818
+ return {
819
+ body: result.items,
820
+ headers: links.length > 0 ? { link: links.join(", ") } : {},
821
+ };
822
+ }
823
+ function requireJson(req, defects) {
824
+ if (defects.has("CONTENT_TYPE_NOT_ENFORCED"))
825
+ return;
826
+ const type = req.headers["content-type"];
827
+ if (typeof type !== "string" || !type.includes("application/json")) {
828
+ throw new HttpError(415, "unsupported_media_type", "expected application/json");
829
+ }
830
+ }
831
+ async function readJson(req) {
832
+ const chunks = [];
833
+ for await (const chunk of req)
834
+ chunks.push(chunk);
835
+ const text = Buffer.concat(chunks).toString("utf8");
836
+ if (text.trim() === "")
837
+ return {};
838
+ try {
839
+ return JSON.parse(text);
840
+ }
841
+ catch {
842
+ throw new HttpError(400, "invalid_input", "request body is not valid JSON");
843
+ }
844
+ }
845
+ /** In-memory reference server — no dependencies, no flags, nothing to have running. */
846
+ export async function createMemoryServer(options = {}) {
847
+ const { MemoryStore } = await import("./stores/memory.js");
848
+ return createReferenceServer(options, async (defects) => new MemoryStore(defects));
849
+ }
850
+ /** SQLite-backed reference server, in-process via `node:sqlite`. */
851
+ export async function createSqliteServer(options = {}) {
852
+ const { SqlStore } = await import("./stores/sqlite.js");
853
+ const { nodeSqliteDriver } = await import("./stores/sqlite-driver.js");
854
+ return createReferenceServer(options, async (defects) => SqlStore.create(defects, await nodeSqliteDriver()));
855
+ }
856
+ /**
857
+ * Cloudflare D1 reference server — the same SQL over the network, against someone else's build.
858
+ *
859
+ * Credentials come from the environment rather than config so a token never reaches a file that
860
+ * could be committed. Tables are prefixed per run and dropped on close, because D1 persists: two
861
+ * runs sharing one database would otherwise contaminate each other's results.
862
+ */
863
+ export async function createD1Server(options = {}) {
864
+ const accountId = options.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
865
+ const databaseId = options.databaseId ?? process.env.CLOUDFLARE_D1_DATABASE_ID;
866
+ const apiToken = options.apiToken ?? process.env.CLOUDFLARE_API_TOKEN;
867
+ const missing = [
868
+ accountId === undefined ? "CLOUDFLARE_ACCOUNT_ID" : null,
869
+ databaseId === undefined ? "CLOUDFLARE_D1_DATABASE_ID" : null,
870
+ apiToken === undefined ? "CLOUDFLARE_API_TOKEN" : null,
871
+ ].filter((name) => name !== null);
872
+ if (missing.length > 0) {
873
+ throw new Error(`D1 backend needs ${missing.join(", ")} in the environment`);
874
+ }
875
+ const { SqlStore } = await import("./stores/sqlite.js");
876
+ const { d1Driver } = await import("./stores/sqlite-driver.js");
877
+ const prefix = `oat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}_`;
878
+ return createReferenceServer(options, (defects) => SqlStore.create(defects, d1Driver({
879
+ accountId: accountId,
880
+ apiToken: apiToken,
881
+ databaseId: databaseId,
882
+ }), prefix));
883
+ }
884
+ /** Postgres-backed reference server. Imported lazily so the driver is only loaded when used. */
885
+ export async function createPostgresServer(options = {}) {
886
+ const { PgStore } = await import("./stores/postgres.js");
887
+ return createReferenceServer(options, (defects) => PgStore.create(defects));
888
+ }
889
+ //# sourceMappingURL=http.js.map