@lovrozagar/oat 0.9.1 → 0.11.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 (283) hide show
  1. package/README.md +26 -1936
  2. package/dist/cli.js +326 -60
  3. package/dist/config/define-config.d.ts +15 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/validate.d.ts +12 -0
  7. package/dist/config/validate.d.ts.map +1 -0
  8. package/dist/config/validate.js +86 -0
  9. package/dist/config/validate.js.map +1 -0
  10. package/dist/conformance/effects.d.ts +9 -0
  11. package/dist/conformance/effects.d.ts.map +1 -0
  12. package/dist/conformance/effects.js +276 -0
  13. package/dist/conformance/effects.js.map +1 -0
  14. package/dist/conformance/expected-failures.d.ts +18 -0
  15. package/dist/conformance/expected-failures.d.ts.map +1 -0
  16. package/dist/conformance/expected-failures.js +48 -0
  17. package/dist/conformance/expected-failures.js.map +1 -0
  18. package/dist/conformance/feature-gate.d.ts +7 -0
  19. package/dist/conformance/feature-gate.d.ts.map +1 -0
  20. package/dist/conformance/feature-gate.js +428 -0
  21. package/dist/conformance/feature-gate.js.map +1 -0
  22. package/dist/conformance/fuzz.d.ts +108 -0
  23. package/dist/conformance/fuzz.d.ts.map +1 -0
  24. package/dist/conformance/fuzz.js +246 -0
  25. package/dist/conformance/fuzz.js.map +1 -0
  26. package/dist/conformance/leaks.d.ts +10 -0
  27. package/dist/conformance/leaks.d.ts.map +1 -0
  28. package/dist/conformance/leaks.js +23 -0
  29. package/dist/conformance/leaks.js.map +1 -0
  30. package/dist/conformance/pool.d.ts +49 -0
  31. package/dist/conformance/pool.d.ts.map +1 -0
  32. package/dist/conformance/pool.js +75 -0
  33. package/dist/conformance/pool.js.map +1 -0
  34. package/dist/conformance/rate-limit.d.ts +7 -0
  35. package/dist/conformance/rate-limit.d.ts.map +1 -0
  36. package/dist/conformance/rate-limit.js +372 -0
  37. package/dist/conformance/rate-limit.js.map +1 -0
  38. package/dist/conformance/scope.d.ts +15 -0
  39. package/dist/conformance/scope.d.ts.map +1 -0
  40. package/dist/conformance/scope.js +78 -0
  41. package/dist/conformance/scope.js.map +1 -0
  42. package/dist/conformance/seed-contract.d.ts +11 -0
  43. package/dist/conformance/seed-contract.d.ts.map +1 -0
  44. package/dist/conformance/seed-contract.js +680 -0
  45. package/dist/conformance/seed-contract.js.map +1 -0
  46. package/dist/conformance/shapes.d.ts +35 -0
  47. package/dist/conformance/shapes.d.ts.map +1 -0
  48. package/dist/conformance/shapes.js +162 -0
  49. package/dist/conformance/shapes.js.map +1 -0
  50. package/dist/conformance/specs.d.ts +21 -0
  51. package/dist/conformance/specs.d.ts.map +1 -0
  52. package/dist/conformance/specs.js +306 -0
  53. package/dist/conformance/specs.js.map +1 -0
  54. package/dist/conformance/suite.d.ts +209 -0
  55. package/dist/conformance/suite.d.ts.map +1 -0
  56. package/dist/conformance/suite.js +1538 -0
  57. package/dist/conformance/suite.js.map +1 -0
  58. package/dist/conformance/unique.d.ts +7 -0
  59. package/dist/conformance/unique.d.ts.map +1 -0
  60. package/dist/conformance/unique.js +586 -0
  61. package/dist/conformance/unique.js.map +1 -0
  62. package/dist/conformance/worker.d.ts +3 -0
  63. package/dist/conformance/worker.d.ts.map +1 -0
  64. package/dist/conformance/worker.js +27 -0
  65. package/dist/conformance/worker.js.map +1 -0
  66. package/dist/index.d.ts +10 -3
  67. package/dist/index.js +6 -2
  68. package/dist/reference/defects.d.ts +71 -1
  69. package/dist/reference/defects.d.ts.map +1 -1
  70. package/dist/reference/defects.js +74 -8
  71. package/dist/reference/defects.js.map +1 -1
  72. package/dist/reference/dialect.d.ts +6 -4
  73. package/dist/reference/dialect.d.ts.map +1 -1
  74. package/dist/reference/dialect.js +1 -0
  75. package/dist/reference/dialect.js.map +1 -1
  76. package/dist/reference/http.d.ts +17 -25
  77. package/dist/reference/http.d.ts.map +1 -1
  78. package/dist/reference/http.js +574 -224
  79. package/dist/reference/http.js.map +1 -1
  80. package/dist/reference/listing.d.ts +51 -0
  81. package/dist/reference/listing.d.ts.map +1 -0
  82. package/dist/reference/listing.js +256 -0
  83. package/dist/reference/listing.js.map +1 -0
  84. package/dist/reference/model.d.ts +35 -1
  85. package/dist/reference/model.d.ts.map +1 -1
  86. package/dist/reference/model.js +42 -0
  87. package/dist/reference/model.js.map +1 -1
  88. package/dist/reference/query.d.ts +132 -43
  89. package/dist/reference/query.d.ts.map +1 -1
  90. package/dist/reference/query.js +397 -315
  91. package/dist/reference/query.js.map +1 -1
  92. package/dist/reference/shapes.d.ts +60 -0
  93. package/dist/reference/shapes.d.ts.map +1 -0
  94. package/dist/reference/shapes.js +142 -0
  95. package/dist/reference/shapes.js.map +1 -0
  96. package/dist/reference/spec.d.ts +9 -2
  97. package/dist/reference/spec.d.ts.map +1 -1
  98. package/dist/reference/spec.js +291 -71
  99. package/dist/reference/spec.js.map +1 -1
  100. package/dist/reference/store-api.d.ts +14 -43
  101. package/dist/reference/store-api.d.ts.map +1 -1
  102. package/dist/reference/store-api.js +8 -58
  103. package/dist/reference/store-api.js.map +1 -1
  104. package/dist/reference/stores/memory.d.ts +12 -15
  105. package/dist/reference/stores/memory.d.ts.map +1 -1
  106. package/dist/reference/stores/memory.js +22 -70
  107. package/dist/reference/stores/memory.js.map +1 -1
  108. package/dist/reference/stores/postgres.d.ts +11 -23
  109. package/dist/reference/stores/postgres.d.ts.map +1 -1
  110. package/dist/reference/stores/postgres.js +53 -334
  111. package/dist/reference/stores/postgres.js.map +1 -1
  112. package/dist/reference/stores/sql-compile.d.ts +36 -0
  113. package/dist/reference/stores/sql-compile.d.ts.map +1 -0
  114. package/dist/reference/stores/sql-compile.js +95 -0
  115. package/dist/reference/stores/sql-compile.js.map +1 -0
  116. package/dist/reference/stores/sqlite.d.ts +12 -27
  117. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  118. package/dist/reference/stores/sqlite.js +54 -355
  119. package/dist/reference/stores/sqlite.js.map +1 -1
  120. package/dist/report/console.d.ts +2 -1
  121. package/dist/report/console.d.ts.map +1 -1
  122. package/dist/report/console.js +56 -10
  123. package/dist/report/console.js.map +1 -1
  124. package/dist/report/matrix.d.ts +10 -13
  125. package/dist/report/matrix.d.ts.map +1 -1
  126. package/dist/report/matrix.js +82 -18
  127. package/dist/report/matrix.js.map +1 -1
  128. package/dist/report/render.d.ts +18 -1
  129. package/dist/report/render.d.ts.map +1 -1
  130. package/dist/report/render.js +169 -14
  131. package/dist/report/render.js.map +1 -1
  132. package/dist/runtime/async.d.ts +16 -3
  133. package/dist/runtime/async.d.ts.map +1 -1
  134. package/dist/runtime/async.js +24 -19
  135. package/dist/runtime/async.js.map +1 -1
  136. package/dist/runtime/auth.d.ts +2 -9
  137. package/dist/runtime/auth.d.ts.map +1 -1
  138. package/dist/runtime/auth.js +38 -62
  139. package/dist/runtime/auth.js.map +1 -1
  140. package/dist/runtime/body.d.ts +15 -3
  141. package/dist/runtime/body.d.ts.map +1 -1
  142. package/dist/runtime/body.js +61 -12
  143. package/dist/runtime/body.js.map +1 -1
  144. package/dist/runtime/check-costs.d.ts +15 -0
  145. package/dist/runtime/check-costs.d.ts.map +1 -0
  146. package/dist/runtime/check-costs.js +114 -0
  147. package/dist/runtime/check-costs.js.map +1 -0
  148. package/dist/runtime/check-ids.d.ts +15 -0
  149. package/dist/runtime/check-ids.d.ts.map +1 -0
  150. package/dist/runtime/check-ids.js +140 -0
  151. package/dist/runtime/check-ids.js.map +1 -0
  152. package/dist/runtime/checks.d.ts +122 -18
  153. package/dist/runtime/checks.d.ts.map +1 -1
  154. package/dist/runtime/checks.js +3437 -1933
  155. package/dist/runtime/checks.js.map +1 -1
  156. package/dist/runtime/client.d.ts +112 -0
  157. package/dist/runtime/client.d.ts.map +1 -1
  158. package/dist/runtime/client.js +271 -39
  159. package/dist/runtime/client.js.map +1 -1
  160. package/dist/runtime/cookies.d.ts +16 -2
  161. package/dist/runtime/cookies.d.ts.map +1 -1
  162. package/dist/runtime/cookies.js +83 -22
  163. package/dist/runtime/cookies.js.map +1 -1
  164. package/dist/runtime/exchanges.d.ts +4 -5
  165. package/dist/runtime/exchanges.d.ts.map +1 -1
  166. package/dist/runtime/exchanges.js +33 -55
  167. package/dist/runtime/exchanges.js.map +1 -1
  168. package/dist/runtime/exit.d.ts +40 -0
  169. package/dist/runtime/exit.d.ts.map +1 -0
  170. package/dist/runtime/exit.js +48 -0
  171. package/dist/runtime/exit.js.map +1 -0
  172. package/dist/runtime/feature-gate.d.ts +0 -9
  173. package/dist/runtime/feature-gate.d.ts.map +1 -1
  174. package/dist/runtime/feature-gate.js +0 -21
  175. package/dist/runtime/feature-gate.js.map +1 -1
  176. package/dist/runtime/finding.d.ts +61 -7
  177. package/dist/runtime/finding.d.ts.map +1 -1
  178. package/dist/runtime/finding.js +74 -12
  179. package/dist/runtime/finding.js.map +1 -1
  180. package/dist/runtime/fixture.d.ts +35 -28
  181. package/dist/runtime/fixture.d.ts.map +1 -1
  182. package/dist/runtime/fixture.js +92 -731
  183. package/dist/runtime/fixture.js.map +1 -1
  184. package/dist/runtime/generate.d.ts +63 -0
  185. package/dist/runtime/generate.d.ts.map +1 -0
  186. package/dist/runtime/generate.js +635 -0
  187. package/dist/runtime/generate.js.map +1 -0
  188. package/dist/runtime/headers.d.ts +6 -0
  189. package/dist/runtime/headers.d.ts.map +1 -0
  190. package/dist/runtime/headers.js +16 -0
  191. package/dist/runtime/headers.js.map +1 -0
  192. package/dist/runtime/path.d.ts +7 -0
  193. package/dist/runtime/path.d.ts.map +1 -0
  194. package/dist/runtime/path.js +44 -0
  195. package/dist/runtime/path.js.map +1 -0
  196. package/dist/runtime/payloads.d.ts +2 -0
  197. package/dist/runtime/payloads.d.ts.map +1 -1
  198. package/dist/runtime/payloads.js +2 -1
  199. package/dist/runtime/payloads.js.map +1 -1
  200. package/dist/runtime/rate-limit.d.ts +6 -4
  201. package/dist/runtime/rate-limit.d.ts.map +1 -1
  202. package/dist/runtime/rate-limit.js +22 -33
  203. package/dist/runtime/rate-limit.js.map +1 -1
  204. package/dist/runtime/redact.d.ts +24 -0
  205. package/dist/runtime/redact.d.ts.map +1 -0
  206. package/dist/runtime/redact.js +98 -0
  207. package/dist/runtime/redact.js.map +1 -0
  208. package/dist/runtime/run.d.ts +51 -2
  209. package/dist/runtime/run.d.ts.map +1 -1
  210. package/dist/runtime/run.js +463 -195
  211. package/dist/runtime/run.js.map +1 -1
  212. package/dist/runtime/runs.d.ts +5 -0
  213. package/dist/runtime/runs.d.ts.map +1 -1
  214. package/dist/runtime/runs.js +7 -1
  215. package/dist/runtime/runs.js.map +1 -1
  216. package/dist/runtime/scope.d.ts +163 -0
  217. package/dist/runtime/scope.d.ts.map +1 -0
  218. package/dist/runtime/scope.js +423 -0
  219. package/dist/runtime/scope.js.map +1 -0
  220. package/dist/runtime/teardown.d.ts +17 -4
  221. package/dist/runtime/teardown.d.ts.map +1 -1
  222. package/dist/runtime/teardown.js +49 -16
  223. package/dist/runtime/teardown.js.map +1 -1
  224. package/dist/runtime/transcript.d.ts +34 -2
  225. package/dist/runtime/transcript.d.ts.map +1 -1
  226. package/dist/runtime/transcript.js +119 -24
  227. package/dist/runtime/transcript.js.map +1 -1
  228. package/dist/runtime/unique.d.ts +2 -11
  229. package/dist/runtime/unique.d.ts.map +1 -1
  230. package/dist/runtime/unique.js +27 -53
  231. package/dist/runtime/unique.js.map +1 -1
  232. package/dist/runtime/validate.d.ts +13 -2
  233. package/dist/runtime/validate.d.ts.map +1 -1
  234. package/dist/runtime/validate.js +51 -31
  235. package/dist/runtime/validate.js.map +1 -1
  236. package/dist/runtime/wait.d.ts +2 -1
  237. package/dist/runtime/wait.d.ts.map +1 -1
  238. package/dist/runtime/wait.js +2 -36
  239. package/dist/runtime/wait.js.map +1 -1
  240. package/dist/runtime/world.d.ts +31 -3
  241. package/dist/runtime/world.d.ts.map +1 -1
  242. package/dist/runtime/world.js +113 -32
  243. package/dist/runtime/world.js.map +1 -1
  244. package/dist/spec/collection.d.ts +8 -2
  245. package/dist/spec/collection.d.ts.map +1 -1
  246. package/dist/spec/collection.js +47 -7
  247. package/dist/spec/collection.js.map +1 -1
  248. package/dist/spec/extensions.d.ts +5 -1
  249. package/dist/spec/extensions.d.ts.map +1 -1
  250. package/dist/spec/extensions.js +9 -2
  251. package/dist/spec/extensions.js.map +1 -1
  252. package/dist/spec/graph.d.ts +46 -0
  253. package/dist/spec/graph.d.ts.map +1 -1
  254. package/dist/spec/graph.js +175 -11
  255. package/dist/spec/graph.js.map +1 -1
  256. package/dist/spec/load.d.ts +25 -5
  257. package/dist/spec/load.d.ts.map +1 -1
  258. package/dist/spec/load.js +160 -59
  259. package/dist/spec/load.js.map +1 -1
  260. package/dist/spec/query-capabilities.d.ts +4 -0
  261. package/dist/spec/query-capabilities.d.ts.map +1 -1
  262. package/dist/spec/query-capabilities.js +6 -0
  263. package/dist/spec/query-capabilities.js.map +1 -1
  264. package/dist/spec/schema.d.ts +20 -0
  265. package/dist/spec/schema.d.ts.map +1 -0
  266. package/dist/spec/schema.js +118 -0
  267. package/dist/spec/schema.js.map +1 -0
  268. package/dist/spec/tag-schema.d.ts +12 -0
  269. package/dist/spec/tag-schema.d.ts.map +1 -0
  270. package/dist/spec/tag-schema.js +143 -0
  271. package/dist/spec/tag-schema.js.map +1 -0
  272. package/dist/spec/types.js +17 -1
  273. package/dist/spec/types.js.map +1 -1
  274. package/docs/api.md +59 -0
  275. package/docs/assumptions.md +99 -0
  276. package/docs/checks.md +183 -0
  277. package/docs/commands.md +214 -0
  278. package/docs/configuration.md +650 -0
  279. package/docs/model.md +184 -0
  280. package/docs/reference-defects.md +94 -0
  281. package/docs/reports.md +211 -0
  282. package/docs/tags.md +400 -0
  283. package/package.json +3 -1
@@ -16,10 +16,14 @@
16
16
  * This is a test fixture. It is excluded from the published package; oat itself never talks to
17
17
  * a database.
18
18
  */
19
+ import { createHash } from "node:crypto";
19
20
  import { createServer } from "node:http";
20
21
  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";
22
+ import { fieldsWhere, shapedEntities, writableFields } from "./model.js";
23
+ import { DIALECTS, toCanonicalFilter } from "./dialect.js";
24
+ import { runListing } from "./listing.js";
25
+ import { DEFAULT_CONVENTIONS, and } from "./query.js";
26
+ import { shapeNamed, shapedDialect } from "./shapes.js";
23
27
  import { buildSpec, buildUntaggedSpec } from "./spec.js";
24
28
  import { SqlError } from "./store-api.js";
25
29
  const PRINCIPALS = [
@@ -29,6 +33,10 @@ const PRINCIPALS = [
29
33
  { key: "key_alpha_viewer", projectId: "proj_alpha", rank: 0, token: "tok_alpha_viewer" },
30
34
  ];
31
35
  const TENANT_FIELD = "project_id";
36
+ /** 48 KiB per record: six of them make one listing page larger than 256 KiB. */
37
+ const PADDING = "oat reference padding · ".repeat(2100).slice(0, 49_152);
38
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
39
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i;
32
40
  class HttpError extends Error {
33
41
  status;
34
42
  errorKey;
@@ -39,7 +47,8 @@ class HttpError extends Error {
39
47
  }
40
48
  }
41
49
  /* ------------------------------------------------------------------ validation */
42
- function validateBody(entity, body, phase, defects) {
50
+ function validateBody(entity, body, mode, defects) {
51
+ const phase = mode === "create" ? "create" : "update";
43
52
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
44
53
  throw new HttpError(400, "invalid_input", "request body must be a JSON object");
45
54
  }
@@ -58,7 +67,8 @@ function validateBody(entity, body, phase, defects) {
58
67
  }
59
68
  throw new HttpError(400, "invalid_input", `unknown field "${key}"`);
60
69
  }
61
- if (phase === "create") {
70
+ /* A create, and a replacing update, must carry every required field. */
71
+ if (mode !== "update") {
62
72
  for (const field of allowed.values()) {
63
73
  if (field.required === true && input[field.name] === undefined && !defects.has("REQUIRED_NOT_VALIDATED")) {
64
74
  throw new HttpError(400, "invalid_input", `field "${field.name}" is required`);
@@ -82,14 +92,34 @@ function coerceField(field, value, defects) {
82
92
  return null;
83
93
  }
84
94
  if (field.type === "integer" || field.type === "number") {
85
- if (typeof value !== "number") {
95
+ if (typeof value !== "number" || !Number.isFinite(value)) {
86
96
  throw new HttpError(400, "invalid_input", `field "${field.name}" must be a number`);
87
97
  }
88
98
  if (field.type === "integer" && !Number.isInteger(value)) {
89
99
  throw new HttpError(400, "invalid_input", `field "${field.name}" must be an integer`);
90
100
  }
101
+ if ((field.minimum !== undefined && value < field.minimum) ||
102
+ (field.maximum !== undefined && value > field.maximum) ||
103
+ (field.multipleOf !== undefined && !Number.isInteger(value / field.multipleOf))) {
104
+ throw new HttpError(400, "invalid_input", `field "${field.name}" is out of range`);
105
+ }
91
106
  return value;
92
107
  }
108
+ if (field.type === "array") {
109
+ const allowed = field.items ?? [];
110
+ if (!Array.isArray(value) ||
111
+ value.length > 5 ||
112
+ value.some((element) => typeof element !== "string" || !allowed.includes(element))) {
113
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a list of ${allowed.join(", ")}`);
114
+ }
115
+ return [...value];
116
+ }
117
+ if (field.type === "object") {
118
+ if (field.ref !== "Point" || !isPoint(value)) {
119
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a ${field.ref ?? "object"}`);
120
+ }
121
+ return { x: value.x, y: value.y };
122
+ }
93
123
  if (field.type === "boolean") {
94
124
  if (typeof value !== "boolean") {
95
125
  throw new HttpError(400, "invalid_input", `field "${field.name}" must be a boolean`);
@@ -105,14 +135,72 @@ function coerceField(field, value, defects) {
105
135
  if (field.enum !== undefined && !field.enum.includes(value) && !defects.has("ENUM_NOT_VALIDATED")) {
106
136
  throw new HttpError(400, "invalid_input", `field "${field.name}" must be one of ${field.enum.join(", ")}`);
107
137
  }
138
+ if (field.const !== undefined && value !== field.const) {
139
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be "${field.const}"`);
140
+ }
141
+ if (field.format === "date-time" && (!DATE_TIME.test(value) || Number.isNaN(Date.parse(value)))) {
142
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be an RFC 3339 date-time`);
143
+ }
144
+ if (field.format === "uuid" && !UUID.test(value)) {
145
+ throw new HttpError(400, "invalid_input", `field "${field.name}" must be a UUID`);
146
+ }
108
147
  if (defects.has("STRING_PAYLOAD_MANGLED")) {
109
148
  return value.replace(/[^\x20-\x7E]/g, "").trim();
110
149
  }
111
150
  return value;
112
151
  }
113
- function matchRoute(pathname) {
152
+ /** The value a field takes when nobody supplied one. */
153
+ function emptyValue(field) {
154
+ if (field.nullable === true)
155
+ return null;
156
+ if (field.type === "array")
157
+ return [];
158
+ if (field.type === "boolean")
159
+ return false;
160
+ if (field.type === "integer" || field.type === "number")
161
+ return 0;
162
+ return field.enum?.[0] ?? "";
163
+ }
164
+ function isPoint(value) {
165
+ if (value === null || typeof value !== "object" || Array.isArray(value))
166
+ return false;
167
+ const keys = Object.keys(value);
168
+ const { x, y } = value;
169
+ return keys.length === 2 && Number.isInteger(x) && Number.isInteger(y);
170
+ }
171
+ /**
172
+ * Reads a form-encoded body into the types the schema declares.
173
+ *
174
+ * A form carries only strings, so a correct backend coerces each field by its declared type —
175
+ * exactly what it would have to do in production.
176
+ */
177
+ function formFields(entity, text) {
178
+ const out = {};
179
+ for (const [key, raw] of new URLSearchParams(text)) {
180
+ const field = entity.fields.find((candidate) => candidate.name === key);
181
+ if (field === undefined) {
182
+ out[key] = raw;
183
+ continue;
184
+ }
185
+ if (field.type === "array") {
186
+ /* A form repeats the key once per element. */
187
+ out[key] = [...(out[key] ?? []), raw];
188
+ }
189
+ else if (field.type === "integer" || field.type === "number") {
190
+ out[key] = raw.trim() === "" ? raw : Number(raw);
191
+ }
192
+ else if (field.type === "boolean") {
193
+ out[key] = raw === "true" ? true : raw === "false" ? false : raw;
194
+ }
195
+ else {
196
+ out[key] = raw;
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ function matchRoute(pathname, entities) {
114
202
  const segments = pathname.split("/").filter(Boolean);
115
- for (const entity of ENTITIES) {
203
+ for (const entity of entities) {
116
204
  for (const [template, isItem] of [
117
205
  [entity.itemPath, true],
118
206
  [entity.collectionPath, false],
@@ -130,7 +218,7 @@ function matchRoute(pathname) {
130
218
  break;
131
219
  }
132
220
  if (part.startsWith("{"))
133
- scope[part.slice(1, -1)] = value;
221
+ scope[part.slice(1, -1)] = decodeURIComponent(value);
134
222
  else if (part !== value) {
135
223
  ok = false;
136
224
  break;
@@ -143,6 +231,13 @@ function matchRoute(pathname) {
143
231
  }
144
232
  return null;
145
233
  }
234
+ function resolveDialect(name) {
235
+ const dialect = DIALECTS[name ?? "postgrest"];
236
+ if (dialect === undefined) {
237
+ throw new Error(`unknown dialect "${name}" — expected one of ${Object.keys(DIALECTS).join(", ")}`);
238
+ }
239
+ return dialect;
240
+ }
146
241
  /**
147
242
  * One server, any store. Defining a defect once and exercising it on every engine is what makes
148
243
  * a disagreement between engines meaningful rather than an artefact of two implementations.
@@ -153,13 +248,75 @@ export async function createReferenceServer(options,
153
248
  * runtime (node:sqlite behind a flag) would then take down paths that never touch it. */
154
249
  createStore) {
155
250
  const defects = new Defects(options.defects ?? []);
156
- const dialect = DIALECTS[options.dialect ?? "postgrest"] ?? POSTGREST;
157
- const store = await createStore(defects);
251
+ const shape = typeof options.shape === "string" ? shapeNamed(options.shape).shape : (options.shape ?? {});
252
+ const dialect = shapedDialect(resolveDialect(options.dialect), shape);
253
+ const entities = shapedEntities(shape);
254
+ const TABLE = entities.find((entity) => entity.name === "table");
255
+ const JOB = entities.find((entity) => entity.name === "job");
256
+ const conventions = { ...DEFAULT_CONVENTIONS, ...shape.conventions };
257
+ const basePath = shape.basePath ?? "";
258
+ const updateMethod = shape.updateMethod ?? "PATCH";
259
+ const store = await createStore(defects, entities);
158
260
  const jobStartedAt = new Map();
159
261
  /* Snapshot of a collection taken before a write, replayed while STALE_LIST is on. */
160
262
  const staleSnapshot = new Map();
263
+ /* Entities whose first listing has already been refused, for RATE_LIMIT_STRICTER_THAN_DECLARED. */
264
+ const throttled = new Set();
161
265
  /* Idempotency-Key → the record the first request created, so a replay returns it. */
162
266
  const idempotent = new Map();
267
+ /* Delivered notifications, per project. */
268
+ const inboxes = new Map();
269
+ /* Identifiers and timestamps are the server's, not the store's: one sequence, so every engine
270
+ * issues the same ids in the same order. Timestamps derive from it rather than the clock —
271
+ * wall-clock time makes ordering assertions flaky for reasons unrelated to the backend. */
272
+ let sequence = 0;
273
+ const nextId = (entity) => {
274
+ sequence += 1;
275
+ const identity = entity.fields.find((field) => field.name === entity.identity);
276
+ if (identity?.type === "integer")
277
+ return sequence;
278
+ if (identity?.format === "uuid") {
279
+ /* Deterministic but unordered, like a real v4: creation order says nothing about id order. */
280
+ const h = createHash("sha1").update(`oat-ref-${sequence}`).digest("hex");
281
+ const variant = "89ab"[Number.parseInt(h[16] ?? "0", 16) % 4];
282
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
283
+ }
284
+ return `${entity.name}_${String(sequence).padStart(6, "0")}`;
285
+ };
286
+ const now = () => 1_700_000_000_000 + sequence * 1000;
287
+ /**
288
+ * Reads one path identifier as the type its entity declares. A malformed one is a 400 — the
289
+ * strict validation real frameworks apply before a handler ever runs.
290
+ */
291
+ function pathId(param, raw) {
292
+ const owner = entities.find((entity) => entity.itemParam === param);
293
+ const identity = owner?.fields.find((field) => field.name === owner.identity);
294
+ if (identity?.type === "integer") {
295
+ if (!/^[1-9]\d{0,14}$/.test(raw))
296
+ throw new HttpError(400, "invalid_input", `"${raw}" is not a valid ${param}`);
297
+ return Number(raw);
298
+ }
299
+ if (identity?.format === "uuid" && !UUID.test(raw)) {
300
+ throw new HttpError(400, "invalid_input", `"${raw}" is not a valid ${param}`);
301
+ }
302
+ return raw;
303
+ }
304
+ function typedScope(scope) {
305
+ const out = {};
306
+ for (const [param, raw] of Object.entries(scope))
307
+ out[param] = pathId(param, raw);
308
+ return out;
309
+ }
310
+ /** A record as it leaves the server: write-only fields never do. */
311
+ function present(entity, record) {
312
+ const hidden = entity.fields.filter((field) => field.writeOnly === true);
313
+ if (hidden.length === 0)
314
+ return decorate(record);
315
+ const out = { ...record };
316
+ for (const field of hidden)
317
+ delete out[field.name];
318
+ return decorate(out);
319
+ }
163
320
  function authenticate(req) {
164
321
  const header = req.headers.authorization;
165
322
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
@@ -172,6 +329,13 @@ createStore) {
172
329
  }
173
330
  function assertWrite(principal, kind) {
174
331
  /* Viewer cannot write; member cannot delete; owner can do everything. */
332
+ if (kind === "update" && defects.has("ROLE_WRITE_INVERTED")) {
333
+ /* The lattice turned over for updates: the viewer may, the member may not. */
334
+ if (principal.rank === 1)
335
+ throw new HttpError(403, "forbidden", "role cannot write");
336
+ if (principal.rank === 0)
337
+ return;
338
+ }
175
339
  if (kind === "delete" && principal.rank < 2) {
176
340
  throw new HttpError(403, "forbidden", "role cannot delete");
177
341
  }
@@ -185,17 +349,18 @@ createStore) {
185
349
  throw new HttpError(403, "forbidden", "resource belongs to another tenant");
186
350
  }
187
351
  }
352
+ const tombstoned = (entity, record) => entity.softDeleteField !== undefined && record[entity.softDeleteField] !== null;
188
353
  /** Walks a record up its parent chain to the owning tenant, driven by the descriptors. */
189
354
  async function ownedByTenant(entity, record, principal) {
190
355
  if (entity.fields.some((f) => f.name === TENANT_FIELD)) {
191
356
  return record[TENANT_FIELD] === principal.projectId;
192
357
  }
193
358
  for (const parentParam of entity.parents) {
194
- const parentEntity = ENTITIES.find((e) => e.itemParam === parentParam);
359
+ const parentEntity = entities.find((e) => e.itemParam === parentParam);
195
360
  if (parentEntity === undefined)
196
361
  continue;
197
362
  const link = record[parentParam];
198
- if (typeof link !== "string")
363
+ if (typeof link !== "string" && typeof link !== "number")
199
364
  continue;
200
365
  const parent = await store.byId(parentEntity, link);
201
366
  if (parent === null)
@@ -204,13 +369,73 @@ createStore) {
204
369
  }
205
370
  return false;
206
371
  }
207
- function projectJob(job) {
208
- const startedAt = jobStartedAt.get(String(job.id));
209
- if (startedAt === undefined || defects.has("ASYNC_NEVER_COMPLETES"))
210
- return job;
211
- const ratio = Math.min((Date.now() - startedAt) / 60, 1);
212
- const progress = Math.floor(ratio * 100);
213
- return { ...job, progress, status: ratio >= 1 ? "complete" : progress > 0 ? "running" : "pending" };
372
+ /**
373
+ * Every ancestor named in the path must exist, be live, and belong to the caller — and its own
374
+ * ancestors must be the ones the path names. Without this, a caller could list or write rows of
375
+ * another tenant's table by placing that table's id under their own project.
376
+ */
377
+ async function assertParents(entity, scope, principal) {
378
+ for (const param of entity.parents) {
379
+ const parentEntity = entities.find((candidate) => candidate.itemParam === param);
380
+ if (parentEntity === undefined)
381
+ continue;
382
+ const id = scope[param] ?? "";
383
+ const parent = await store.byId(parentEntity, id);
384
+ const linked = parent !== null &&
385
+ parentEntity.parents.every((ancestor) => !parentEntity.fields.some((f) => f.name === ancestor) || parent[ancestor] === scope[ancestor]);
386
+ /* Under the defect a parent only has to exist: whose it is, and whose path names it, go
387
+ * unchecked — another tenant's parent id under the caller's own root is let through. */
388
+ const foreignAllowed = defects.has("FOREIGN_PARENT_ACCEPTED") && parent !== null;
389
+ if (!foreignAllowed &&
390
+ (parent === null ||
391
+ !linked ||
392
+ tombstoned(parentEntity, parent) ||
393
+ !(await ownedByTenant(parentEntity, parent, principal)))) {
394
+ throw new HttpError(404, "not_found", `${parentEntity.name} ${id} does not exist`);
395
+ }
396
+ }
397
+ }
398
+ /**
399
+ * A record is served under the path that names its own parents, and no other — all the way up:
400
+ * a row's table must itself sit under the project the path names.
401
+ */
402
+ async function underPath(entity, record, scope) {
403
+ const stored = (param) => entity.fields.some((f) => f.name === param);
404
+ for (const param of entity.parents) {
405
+ if (stored(param) && scope[param] !== undefined && record[param] !== scope[param])
406
+ return false;
407
+ }
408
+ const parentParam = [...entity.parents].reverse().find((param) => entities.some((e) => e.itemParam === param));
409
+ if (parentParam === undefined || !stored(parentParam))
410
+ return true;
411
+ const parentEntity = entities.find((e) => e.itemParam === parentParam);
412
+ const parent = await store.byId(parentEntity, record[parentParam]);
413
+ return parent !== null && underPath(parentEntity, parent, scope);
414
+ }
415
+ /**
416
+ * Advances started jobs and writes the result back.
417
+ *
418
+ * Progress is persisted rather than computed per response, so a filter on `status` and the
419
+ * body it returns describe the same state. Applied before every job read.
420
+ */
421
+ async function advanceJobs() {
422
+ if (defects.has("ASYNC_NEVER_COMPLETES"))
423
+ return;
424
+ for (const [id, startedAt] of jobStartedAt) {
425
+ const ratio = Math.min((Date.now() - startedAt) / 60, 1);
426
+ const progress = Math.floor(ratio * 100);
427
+ const status = ratio >= 1 ? "complete" : progress > 0 ? "running" : "pending";
428
+ const current = await store.byId(JOB, id);
429
+ if (current === null) {
430
+ jobStartedAt.delete(id);
431
+ continue;
432
+ }
433
+ if (current.status === status && current.progress === progress)
434
+ continue;
435
+ await store.update(JOB, id, { progress, status });
436
+ if (status === "complete")
437
+ jobStartedAt.delete(id);
438
+ }
214
439
  }
215
440
  /**
216
441
  * Rewrites a field in the *collection* projection only.
@@ -231,31 +456,33 @@ createStore) {
231
456
  return record;
232
457
  return { ...record, _internal_revision: 7, _shard: "shard-a" };
233
458
  }
234
- async function assertUnique(entity, record, exceptId, defects) {
459
+ /** The live records of one parent scope — what uniqueness and derived counts range over. */
460
+ async function liveRows(entity, scope) {
461
+ const constraints = Object.entries(scope)
462
+ .filter(([name, value]) => value !== undefined && entity.fields.some((field) => field.name === name))
463
+ .map(([field, value]) => ({
464
+ asText: false,
465
+ field,
466
+ kind: "cmp",
467
+ nullsMatch: false,
468
+ op: "eq",
469
+ value: value,
470
+ }));
471
+ const soft = entity.softDeleteField;
472
+ const where = and(...constraints, soft === undefined ? null : { field: soft, kind: "isnull", negate: false });
473
+ return store.select(entity, { collation: "binary", order: [], shuffleTies: false, where });
474
+ }
475
+ async function assertUnique(entity, record, exceptId) {
235
476
  if (defects.has("UNIQUE_NOT_ENFORCED"))
236
477
  return;
237
478
  const sets = entity.unique ?? [];
238
479
  if (sets.length === 0)
239
480
  return;
240
481
  const scope = {};
241
- for (const parent of entity.parents) {
242
- const value = record[parent];
243
- if (typeof value === "string")
244
- scope[parent] = value;
245
- }
246
- const tenant = record[TENANT_FIELD];
247
- if (typeof tenant === "string")
248
- scope[TENANT_FIELD] = tenant;
249
- const pageSize = Math.max(entity.maxLimit, 100);
250
- const items = [];
251
- for (let page = 1; page <= 200; page++) {
252
- const chunk = await store.query(entity, scope, { limit: pageSize, page }, {
253
- softDeleteField: entity.softDeleteField,
254
- });
255
- items.push(...chunk.items);
256
- if (chunk.items.length < pageSize)
257
- break;
258
- }
482
+ for (const parent of entity.parents)
483
+ scope[parent] = record[parent];
484
+ scope[TENANT_FIELD] = record[TENANT_FIELD];
485
+ const items = await liveRows(entity, scope);
259
486
  for (const set of sets) {
260
487
  const collides = items.some((row) => {
261
488
  if (exceptId !== undefined && String(row[entity.identity]) === exceptId)
@@ -268,29 +495,50 @@ createStore) {
268
495
  }
269
496
  function withDefaults(entity, input, scope) {
270
497
  const record = { ...input };
271
- record[entity.identity] = store.nextId(entity.name);
498
+ record[entity.identity] = nextId(entity);
272
499
  for (const parent of entity.parents) {
273
500
  if (entity.fields.some((f) => f.name === parent))
274
501
  record[parent] = scope[parent] ?? null;
275
502
  }
276
- record.created_at = store.now();
277
- record.updated_at = store.now();
503
+ record.created_at = now();
504
+ record.updated_at = now();
278
505
  if (entity.softDeleteField !== undefined)
279
506
  record[entity.softDeleteField] = null;
507
+ /* A server-generated field large enough that one page of records exceeds 256 KiB. */
508
+ if (entity.fields.some((field) => field.name === "padding"))
509
+ record.padding = PADDING;
280
510
  for (const field of entity.fields) {
281
511
  if (record[field.name] !== undefined)
282
512
  continue;
283
- record[field.name] =
284
- field.nullable === true
285
- ? null
286
- : field.type === "boolean"
287
- ? false
288
- : field.type === "integer" || field.type === "number"
289
- ? 0
290
- : (field.enum?.[0] ?? "");
513
+ record[field.name] = emptyValue(field);
291
514
  }
292
515
  return record;
293
516
  }
517
+ /** Defaults for a replacing update: every writable field the body leaves out is reset. */
518
+ function replacementDefaults(entity) {
519
+ const out = {};
520
+ for (const field of writableFields(entity, "update"))
521
+ out[field.name] = emptyValue(field);
522
+ return out;
523
+ }
524
+ /** The page a plain listing asks for, or `null` when a paging value is not a valid one. */
525
+ function staleWindow(url, dialect, entity) {
526
+ const read = (name, min) => {
527
+ const raw = name === undefined ? null : url.searchParams.get(name);
528
+ if (raw === null)
529
+ return undefined;
530
+ const value = Number(raw);
531
+ return raw.trim() !== "" && Number.isInteger(value) && value >= min ? value : null;
532
+ };
533
+ const limit = read(dialect.params.limit, 1);
534
+ const page = read(dialect.params.page, 1);
535
+ const offset = read(dialect.params.offset, 0);
536
+ if (limit === null || page === null || offset === null)
537
+ return null;
538
+ const size = Math.min(limit ?? entity.defaultLimit, entity.maxLimit);
539
+ const start = offset ?? ((page ?? 1) - 1) * size;
540
+ return { limit: size, offset: start, page: page ?? Math.floor(start / size) + 1 };
541
+ }
294
542
  function snapshotKey(entity, scope) {
295
543
  return `${entity.name}:${scope.project_id ?? ""}:${scope.table_id ?? ""}`;
296
544
  }
@@ -301,33 +549,64 @@ createStore) {
301
549
  * route serves. Under the defect the promise is published and not kept, which is the common
302
550
  * real failure — a denormalised counter or a cached projection that nobody refreshes.
303
551
  */
304
- async function refreshParentCount(entity, scope, principal) {
552
+ async function refreshParentCount(entity, scope) {
305
553
  if (entity.name !== "row")
306
554
  return;
307
555
  if (defects.has("PARENT_PROJECTION_STALE"))
308
556
  return;
309
- const table = ENTITIES.find((candidate) => candidate.name === "table");
557
+ const table = entities.find((candidate) => candidate.name === "table");
310
558
  const tableId = scope.table_id;
311
559
  if (table === undefined || tableId === undefined)
312
560
  return;
313
- /*
314
- * Paged, because the store caps a page at the entity's declared maxLimit — `row` allows 5 —
315
- * so a single large-limit query silently counts one page rather than the collection.
316
- *
317
- * The envelope's own total is deliberately not used either: a defect that corrupts the
318
- * reported count would propagate into this derived value and make the parent look stale
319
- * for a reason that has nothing to do with invalidation.
320
- */
321
- const pageSize = entity.maxLimit;
322
- let total = 0;
323
- for (let page = 1; page <= 200; page++) {
324
- const chunk = await store.query(entity, { ...scope, project_id: principal.projectId }, { limit: pageSize, page }, { softDeleteField: entity.softDeleteField });
325
- total += chunk.items.length;
326
- if (chunk.items.length < pageSize)
327
- break;
328
- }
561
+ const total = (await liveRows(entity, { table_id: tableId })).length;
329
562
  await store.update(table, tableId, { row_count: total });
330
563
  }
564
+ /**
565
+ * PATCH without a lock: read the row, yield, then write every column back — the ORM
566
+ * `save(entity)` pattern. Two concurrent patches to different fields, and the later write
567
+ * reinstates the earlier field's old value. The yield makes the window observable rather
568
+ * than dependent on scheduler luck; it runs only under the defect.
569
+ */
570
+ async function readModifyWrite(entity, id, patch) {
571
+ const current = await store.byId(entity, id);
572
+ if (current === null)
573
+ return null;
574
+ await new Promise((resolve) => setTimeout(resolve, 15));
575
+ return store.update(entity, id, { ...current, ...patch });
576
+ }
577
+ /**
578
+ * Records that exist before oat arrives — what every real environment has.
579
+ *
580
+ * Names alternate case so binary and case-insensitive collations order them differently, some
581
+ * descriptions are null, and values repeat, so the data looks like a lived-in collection rather
582
+ * than a fixture. Written straight to the store: none of it is oat's to clean up.
583
+ */
584
+ async function prepopulate(perTenant) {
585
+ const words = ["apple", "Banana", "cherry", "Date", "elder", "Fig", "grape", "Honeydew", "kiwi", "Lemon"];
586
+ for (const [projectId, count] of [
587
+ ["proj_alpha", perTenant],
588
+ ["proj_beta", Math.ceil(perTenant / 10)],
589
+ ]) {
590
+ for (let i = 0; i < count; i++) {
591
+ const word = words[i % words.length];
592
+ const name = `${word} ${projectId === "proj_alpha" ? "" : "beta "}${String(i).padStart(4, "0")}`;
593
+ const table = withDefaults(TABLE, {
594
+ description: i % 3 === 0 ? null : `pre-existing ${word.toLowerCase()} record`,
595
+ name,
596
+ position: (i * 7) % 100,
597
+ slug: name.toLowerCase().replace(/\s+/g, "-"),
598
+ status: ["active", "draft", "archived"][i % 3],
599
+ }, { project_id: projectId });
600
+ await store.insert(TABLE, table);
601
+ const job = withDefaults(JOB, { kind: ["export", "import", "sync"][i % 3], name, note: i % 4 === 0 ? null : word }, { project_id: projectId });
602
+ job.status = "complete";
603
+ job.progress = 100;
604
+ await store.insert(JOB, job);
605
+ }
606
+ }
607
+ }
608
+ if (shape.prepopulate !== undefined)
609
+ await prepopulate(shape.prepopulate);
331
610
  const grants = new Map();
332
611
  function canReadViaGrant(entityName, id, principal) {
333
612
  if (defects.has("INVITE_NEVER_GRANTS"))
@@ -342,12 +621,19 @@ createStore) {
342
621
  }
343
622
  return false;
344
623
  }
345
- async function findItem(entity, principal, id) {
624
+ async function findItem(entity, principal, id, scope, purpose = "read") {
346
625
  const record = await store.byId(entity, id);
347
626
  if (record === null)
348
627
  throw new HttpError(404, "not_found", `${entity.name} ${id} does not exist`);
349
- const owned = await ownedByTenant(entity, record, principal);
350
- if (!defects.has("CROSS_TENANT_READ") && !owned && !canReadViaGrant(entity.name, id, principal)) {
628
+ /* Writes looked up by id alone: reads stay scoped, so only a write probe can tell. */
629
+ if (purpose === "write" && defects.has("CROSS_TENANT_WRITE"))
630
+ return record;
631
+ /* A record named under somebody else's parents is not this caller's to read, whoever owns it. */
632
+ const inPath = await underPath(entity, record, scope);
633
+ const owned = inPath && (await ownedByTenant(entity, record, principal));
634
+ const granted = inPath && canReadViaGrant(entity.name, String(id), principal);
635
+ /* The leak is a lookup by id alone: no tenant check, and no check of the path either. */
636
+ if (!defects.has("CROSS_TENANT_READ") && !owned && !granted) {
351
637
  /* Correct is 404: the same answer an id that never existed would get. Answering 403
352
638
  * here is the defect — the denial is right, but the *status* confirms the record is
353
639
  * real, which is all an attacker enumerating identifiers needs. */
@@ -355,7 +641,7 @@ createStore) {
355
641
  ? new HttpError(403, "forbidden", `${entity.name} ${id} belongs to another tenant`)
356
642
  : new HttpError(404, "not_found", `${entity.name} ${id} does not exist`);
357
643
  }
358
- if (entity.softDeleteField !== undefined && record[entity.softDeleteField] !== null) {
644
+ if (tombstoned(entity, record)) {
359
645
  throw new HttpError(404, "not_found", `${entity.name} ${id} has been deleted`);
360
646
  }
361
647
  return record;
@@ -363,8 +649,16 @@ createStore) {
363
649
  async function dispatch(req, res) {
364
650
  const url = new URL(req.url ?? "/", "http://localhost");
365
651
  const method = (req.method ?? "GET").toUpperCase();
652
+ /* Mounted under a prefix, the API exists only there. */
653
+ if (basePath !== "") {
654
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
655
+ throw new HttpError(404, "not_found", `no route for ${url.pathname}`);
656
+ }
657
+ url.pathname = url.pathname.slice(basePath.length) || "/";
658
+ }
366
659
  if (url.pathname === "/v1/openapi/spec") {
367
- return send(res, 200, options.untagged === true ? buildUntaggedSpec(dialect) : buildSpec(dialect));
660
+ const context = { entities, shape };
661
+ return send(res, 200, options.untagged === true ? buildUntaggedSpec(dialect, context) : buildSpec(dialect, context));
368
662
  }
369
663
  if (url.pathname === "/v1/auth/token" && method === "POST") {
370
664
  const body = await readJson(req);
@@ -397,11 +691,46 @@ createStore) {
397
691
  }
398
692
  return send(res, 202, defects.has("ASYNC_RECEIPT_MISSING_ID") ? { accepted: true } : { accepted: true, job_id: job.id });
399
693
  }
694
+ /* A write whose effect lands elsewhere, later: the message reaches the inbox after a short
695
+ * delay, the way a queue consumer or a webhook delivery would. x-wait documents it. */
696
+ const notifyMatch = /^\/v1\/projects\/([^/]+)\/notifications$/.exec(url.pathname);
697
+ if (notifyMatch !== null && method === "POST") {
698
+ const principal = authenticate(req);
699
+ const projectId = notifyMatch[1] ?? "";
700
+ if (projectId !== principal.projectId) {
701
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
702
+ }
703
+ assertWrite(principal, "create");
704
+ requireJson(req, defects);
705
+ const message = (await readJson(req)).message;
706
+ if (typeof message !== "string" || message === "" || message.length > 200) {
707
+ throw new HttpError(400, "invalid_input", 'field "message" is required');
708
+ }
709
+ sequence += 1;
710
+ const id = `ntf_${String(sequence).padStart(6, "0")}`;
711
+ if (!defects.has("SIDE_EFFECT_NEVER_ARRIVES")) {
712
+ setTimeout(() => {
713
+ const inbox = inboxes.get(projectId) ?? [];
714
+ inbox.push({ id, message });
715
+ inboxes.set(projectId, inbox);
716
+ }, 40);
717
+ }
718
+ return send(res, 202, { accepted: true, notification_id: id });
719
+ }
720
+ const inboxMatch = /^\/v1\/projects\/([^/]+)\/inbox$/.exec(url.pathname);
721
+ if (inboxMatch !== null && method === "GET") {
722
+ const principal = authenticate(req);
723
+ const projectId = inboxMatch[1] ?? "";
724
+ if (projectId !== principal.projectId) {
725
+ throw new HttpError(403, "forbidden", "resource belongs to another tenant");
726
+ }
727
+ return send(res, 200, { messages: inboxes.get(projectId) ?? [] });
728
+ }
400
729
  const inviteMatch = /^\/v1\/projects\/([^/]+)\/tables\/([^/]+)\/invites$/.exec(url.pathname);
401
730
  if (inviteMatch !== null && method === "POST") {
402
731
  const principal = authenticate(req);
403
732
  const projectId = inviteMatch[1] ?? "";
404
- const tableId = inviteMatch[2] ?? "";
733
+ const tableId = pathId(TABLE.itemParam, inviteMatch[2] ?? "");
405
734
  if (projectId !== principal.projectId) {
406
735
  throw new HttpError(403, "forbidden", "resource belongs to another tenant");
407
736
  }
@@ -414,13 +743,13 @@ createStore) {
414
743
  if (PRINCIPALS.find((p) => p.key === key) === undefined) {
415
744
  throw new HttpError(400, "invalid_input", "unknown invitee key");
416
745
  }
417
- await findItem(TABLE, principal, tableId);
746
+ await findItem(TABLE, principal, tableId, { project_id: projectId });
418
747
  const grant = {
419
748
  accepted: false,
420
749
  entity: "table",
421
750
  grantId: `grn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
422
751
  granteeKey: key,
423
- resourceId: tableId,
752
+ resourceId: String(tableId),
424
753
  token: `inv_${Math.random().toString(36).slice(2, 12)}`,
425
754
  };
426
755
  grants.set(grant.grantId, grant);
@@ -443,7 +772,7 @@ createStore) {
443
772
  if (revokeMatch !== null && method === "DELETE") {
444
773
  const principal = authenticate(req);
445
774
  const projectId = revokeMatch[1] ?? "";
446
- const tableId = revokeMatch[2] ?? "";
775
+ const tableId = String(pathId(TABLE.itemParam, revokeMatch[2] ?? ""));
447
776
  const grantId = revokeMatch[3] ?? "";
448
777
  if (projectId !== principal.projectId) {
449
778
  throw new HttpError(403, "forbidden", "resource belongs to another tenant");
@@ -457,112 +786,109 @@ createStore) {
457
786
  grants.delete(grantId);
458
787
  return send(res, 200, { revoked: true });
459
788
  }
460
- const match = matchRoute(url.pathname);
789
+ const match = matchRoute(url.pathname, entities);
461
790
  if (match === null)
462
791
  throw new HttpError(404, "not_found", `no route for ${url.pathname}`);
463
792
  const principal = authenticate(req);
464
- const { entity, scope, itemId } = match;
793
+ const { entity } = match;
794
+ const scope = typedScope(match.scope);
795
+ const itemId = match.itemId === null ? null : (scope[entity.itemParam] ?? null);
465
796
  /* Item GET may be a delegated read — assertTenant would 403 a valid grant before
466
797
  * findItem can honour it. Collection GET and mutations stay tenant-bound. */
467
- if (!(method === "GET" && itemId !== null))
468
- assertTenant(principal, match.scope);
798
+ const delegatedRead = method === "GET" && itemId !== null;
799
+ if (!delegatedRead) {
800
+ assertTenant(principal, scope);
801
+ await assertParents(entity, scope, principal);
802
+ }
803
+ if (entity.name === JOB.name && method === "GET")
804
+ await advanceJobs();
469
805
  if (itemId === null) {
470
806
  if (method === "GET") {
807
+ if (defects.has("RATE_LIMIT_STRICTER_THAN_DECLARED") && !throttled.has(entity.name)) {
808
+ throttled.add(entity.name);
809
+ return send(res, 429, { error: "rate limited" }, { "retry-after": "0" });
810
+ }
471
811
  const key = snapshotKey(entity, scope);
472
812
  /* Only the default listing is served stale. Freezing filtered queries too would
473
813
  * also swallow filter validation, so the defect would masquerade as several
474
814
  * unrelated ones. */
475
815
  const plainListing = [...url.searchParams.keys()].every((k) => k === dialect.params.limit || k === dialect.params.page || k === dialect.params.offset);
476
- if (defects.has("STALE_LIST") && plainListing && staleSnapshot.has(key)) {
816
+ /* The stale rows are still paged as asked: the lie is what the listing holds, not how
817
+ * it pages. Paging values the live path would refuse go there to be refused. */
818
+ const window = plainListing ? staleWindow(url, dialect, entity) : null;
819
+ if (defects.has("STALE_LIST") && window !== null && staleSnapshot.has(key)) {
477
820
  const frozen = staleSnapshot.get(key) ?? [];
821
+ const items = frozen.slice(window.offset, window.offset + window.limit);
478
822
  const stale = paginated(dialect, entity, url, {
479
823
  count: frozen.length,
480
- hasMore: false,
481
- items: frozen,
482
- limit: entity.defaultLimit,
824
+ hasMore: window.offset + items.length < frozen.length,
825
+ items,
826
+ limit: window.limit,
483
827
  nextCursor: null,
484
- page: 1,
828
+ offset: window.offset,
829
+ page: window.page,
485
830
  });
486
831
  return send(res, 200, stale.body, stale.headers);
487
832
  }
488
- const number = (name) => {
489
- const raw = url.searchParams.get(name);
490
- if (raw === null)
491
- return undefined;
492
- const parsed = Number(raw);
493
- if (!Number.isFinite(parsed)) {
494
- throw new HttpError(400, "invalid_input", `query parameter "${name}" must be numeric`);
495
- }
496
- return parsed;
497
- };
498
833
  const rawFilter = dialect.grammar === "equality" ? equalityFilter(url, entity) : url.searchParams.get(dialect.params.filter);
499
834
  let filter;
500
- /* Alone, the filter behaves perfectly. Each of these defects drops it the moment
501
- * another axis joins the request — the combination is the bug, not either axis. */
502
- const sorted = url.searchParams.get(dialect.params.order);
503
- const selected = readSelect(url, dialect, entity);
504
- const searched = url.searchParams.get(dialect.params.search);
505
- const hasSort = sorted !== null && sorted !== "";
506
- const hasSelect = selected !== undefined && selected !== "" && selected !== "*";
507
- const hasSearch = searched !== null && searched !== "";
508
- const dropFilter = (defects.has("FILTER_DROPPED_WHEN_SORTED") && hasSort) ||
509
- (defects.has("FILTER_DROPPED_WHEN_SELECTED") && hasSelect) ||
510
- (defects.has("FILTER_DROPPED_WHEN_SEARCHED") && hasSearch) ||
511
- (defects.has("FILTER_DROPPED_WHEN_SORTED_AND_SELECTED") && hasSort && hasSelect) ||
512
- (defects.has("FILTER_DROPPED_WHEN_SORTED_AND_SEARCHED") && hasSort && hasSearch) ||
513
- (defects.has("FILTER_DROPPED_WHEN_SEARCHED_AND_SELECTED") && hasSearch && hasSelect);
514
- if (rawFilter !== null && rawFilter !== "" && !dropFilter) {
835
+ if (rawFilter !== null && rawFilter !== "") {
515
836
  const canonical = toCanonicalFilter(rawFilter, dialect);
516
- if (canonical === null) {
517
- /* A filter the dialect cannot parse is bad client input, so 400 — unless the
518
- * defect that turns parse failures into server errors is active. Routing this
519
- * path through the same defect matters: on a dialect whose grammar rejects a
520
- * malformed value here rather than in the store, the store's 500 path is never
521
- * reached and the defect would be silently inexpressible. */
522
- throw defects.has("ERROR_500_ON_BAD_FILTER")
523
- ? new HttpError(500, "internal_error", "filter parser threw")
524
- : new HttpError(400, "invalid_input", `malformed filter: ${rawFilter}`);
525
- }
837
+ if (canonical === null)
838
+ throw new SqlError("invalid_filter", `malformed filter: ${rawFilter}`);
526
839
  filter = canonical;
527
840
  }
528
- /*
529
- * Under the defect the predicate is withheld from the store, so the page window is
530
- * computed over the unfiltered set; the filter is then applied to whatever that
531
- * window contained. Modelled here rather than in each store because it is a bug in
532
- * *ordering of operations*, not in any one engine's query compiler.
533
- */
534
- const pageBeforeFilter = defects.has("FILTER_AFTER_PAGINATION") && filter !== undefined;
535
- const result = await store.query(entity, { ...scope, project_id: principal.projectId }, {
536
- cursor: dialect.params.cursor === undefined
537
- ? undefined
538
- : (url.searchParams.get(dialect.params.cursor) ?? undefined),
539
- filter: pageBeforeFilter ? undefined : filter,
540
- limit: number(dialect.params.limit),
541
- order: toCanonicalOrder(url.searchParams.get(dialect.params.order) ?? undefined, dialect),
542
- /* Whichever the dialect publishes. The store pages by number, so an offset is
543
- * converted using the page size actually in force — the same arithmetic the
544
- * caller performed to produce the offset. */
545
- page: pageFrom(url, dialect, number(dialect.params.limit) ?? entity.defaultLimit),
546
- q: searched ?? undefined,
547
- select: selected,
548
- }, {
549
- softDeleteField: entity.softDeleteField,
841
+ const param = (name) => name === undefined ? undefined : (url.searchParams.get(name) ?? undefined);
842
+ const request = {
843
+ cursor: param(dialect.params.cursor),
844
+ filter,
845
+ limit: param(dialect.params.limit),
846
+ offset: param(dialect.params.offset),
847
+ order: toCanonicalOrder(param(dialect.params.order), dialect),
848
+ page: param(dialect.params.page),
849
+ q: param(dialect.params.search),
850
+ searchMode: param(dialect.params.searchMode),
851
+ select: readSelect(url, dialect, entity),
852
+ };
853
+ const result = await runListing(store, entity, request, {
854
+ conventions,
855
+ clampLimit: shape.clampLimit,
856
+ /* A row embeds the table it belongs to: `select=id,table(name)`. */
857
+ embed: async (relation, row) => {
858
+ const declared = entity.relations?.[relation];
859
+ const target = entities.find((candidate) => candidate.name === declared?.entity);
860
+ const link = declared === undefined ? undefined : row[declared.via];
861
+ if (target === undefined || (typeof link !== "string" && typeof link !== "number"))
862
+ return null;
863
+ const related = await store.byId(target, link);
864
+ return related === null ? null : present(target, related);
865
+ },
866
+ scope: { ...scope, project_id: principal.projectId },
550
867
  /* Applied to the collection only — the item route never passes through here —
551
868
  * so a skewed field makes the two projections disagree exactly as a stale
552
869
  * denormalised listing does. */
553
- transform: (row) => skewForList(entity.name === "job" ? projectJob(row) : row),
554
- });
555
- if (pageBeforeFilter && filter !== undefined) {
556
- const matching = await store.query(entity, { ...scope, project_id: principal.projectId }, { filter, limit: 1000 }, { softDeleteField: entity.softDeleteField });
557
- const allowed = new Set(matching.items.map((row) => String(row[entity.identity])));
558
- result.items = result.items.filter((row) => allowed.has(String(row[entity.identity])));
559
- }
560
- const listing = paginated(dialect, entity, url, result);
870
+ transform: skewForList,
871
+ }, defects);
872
+ /* An unknown parameter read as a filter nothing matches. */
873
+ const known = new Set([
874
+ ...Object.values(dialect.params).filter((value) => typeof value === "string"),
875
+ dialect.selectGrammar === "bracketed" ? `${dialect.params.select}[${entity.name}]` : "",
876
+ ...(dialect.grammar === "equality" ? fieldsWhere(entity, "filterable") : []),
877
+ ]);
878
+ const unknownGiven = [...url.searchParams.keys()].some((key) => !known.has(key));
879
+ const served = defects.has("UNKNOWN_PARAM_EMPTIES_LIST") && unknownGiven
880
+ ? { ...result, count: 0, hasMore: false, items: [], nextCursor: null }
881
+ : result;
882
+ const listing = paginated(dialect, entity, url, served);
561
883
  return send(res, 200, listing.body, listing.headers);
562
884
  }
563
885
  if (method === "POST") {
564
886
  assertWrite(principal, "create");
565
- requireJson(req, defects);
887
+ const form = shape.formCreate === true;
888
+ if (form)
889
+ requireForm(req, defects);
890
+ else
891
+ requireJson(req, defects);
566
892
  /* Scoped by principal as well as key: two tenants using the same key must not be
567
893
  * able to read each other's result back. */
568
894
  const idempotencyKey = req.headers["idempotency-key"];
@@ -572,27 +898,40 @@ createStore) {
572
898
  if (replayKey !== null && !defects.has("IDEMPOTENCY_IGNORED")) {
573
899
  const previous = idempotent.get(replayKey);
574
900
  if (previous !== undefined) {
575
- return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, decorate(previous));
901
+ if (defects.has("IDEMPOTENT_REPLAY_INSERTS")) {
902
+ const copy = { ...previous };
903
+ delete copy[entity.identity];
904
+ for (const column of (entity.unique ?? []).flat()) {
905
+ if (typeof copy[column] === "string")
906
+ copy[column] = `${copy[column]}-replay`;
907
+ }
908
+ await store.insert(entity, withDefaults(entity, copy, { ...scope, project_id: principal.projectId }));
909
+ }
910
+ return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, present(entity, previous));
576
911
  }
577
912
  }
578
- const input = validateBody(entity, await readJson(req), "create", defects);
913
+ const received = form ? formFields(entity, await readText(req)) : await readJson(req);
914
+ /* The crash happens on the bytes as received, before anything sanitizes them. */
915
+ if (defects.has("CREATE_500_ON_NON_ASCII") &&
916
+ Object.values(received).some((value) => typeof value === "string" && [...value].some((ch) => (ch.codePointAt(0) ?? 0) > 0x7f))) {
917
+ throw new Error("encoding error: could not write row");
918
+ }
919
+ const input = validateBody(entity, received, "create", defects);
579
920
  if (defects.has("STALE_LIST")) {
580
921
  const key = snapshotKey(entity, scope);
581
922
  if (!staleSnapshot.has(key)) {
582
- staleSnapshot.set(key, (await store.query(entity, { ...scope, project_id: principal.projectId }, { limit: 1000 }, { softDeleteField: entity.softDeleteField })).items);
923
+ staleSnapshot.set(key, await liveRows(entity, { ...scope, project_id: principal.projectId }));
583
924
  }
584
925
  }
585
926
  if (defects.has("CREATE_DROPS_FIELD"))
586
927
  delete input.description;
587
928
  const record = withDefaults(entity, input, { ...scope, project_id: principal.projectId });
588
- await assertUnique(entity, record, undefined, defects);
929
+ await assertUnique(entity, record, undefined);
589
930
  const created = await store.insert(entity, record);
590
- /* Progress starts on POST .../jobs/start, not on create. A seeded job that
591
- * ticks for 60ms looks like a PATCH side effect once x-generated is stripped. */
592
- await refreshParentCount(entity, scope, principal);
931
+ await refreshParentCount(entity, scope);
593
932
  if (replayKey !== null)
594
933
  idempotent.set(replayKey, created);
595
- return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, decorate(created));
934
+ return send(res, defects.has("CREATED_201_AS_200") ? 200 : 201, present(entity, created));
596
935
  }
597
936
  throw new HttpError(404, "not_found", `method ${method} not supported here`);
598
937
  }
@@ -602,14 +941,15 @@ createStore) {
602
941
  if (defects.has("ROLE_MONOTONICITY_BROKEN") && principal.rank === 1) {
603
942
  throw new HttpError(403, "forbidden", "role cannot read this record");
604
943
  }
605
- const record = await findItem(entity, principal, itemId);
606
- return send(res, 200, decorate(entity.name === "job" ? projectJob(record) : record));
944
+ const record = await findItem(entity, principal, itemId, scope);
945
+ return send(res, 200, present(entity, record));
607
946
  }
608
- if (method === "PATCH") {
947
+ if (method === updateMethod) {
609
948
  assertWrite(principal, "update");
610
949
  requireJson(req, defects);
611
- const existing = await findItem(entity, principal, itemId);
612
- const patch = validateBody(entity, await readJson(req), "update", defects);
950
+ const existing = await findItem(entity, principal, itemId, scope, "write");
951
+ const replace = method === "PUT";
952
+ const patch = validateBody(entity, await readJson(req), replace ? "replace" : "update", defects);
613
953
  /*
614
954
  * Write only the fields the caller named.
615
955
  *
@@ -618,22 +958,27 @@ createStore) {
618
958
  * commits second reinstates the values it read before the first had committed. The
619
959
  * merged record is still built, but only to shape the response.
620
960
  */
961
+ /* PUT is a replacement by contract: what the body leaves out is reset, not kept. */
621
962
  const changes = defects.has("PATCH_REPLACES")
622
963
  ? {
623
964
  ...withDefaults(entity, {}, { ...scope, project_id: principal.projectId }),
624
965
  ...patch,
625
966
  }
626
- : { ...patch };
627
- changes.updated_at = store.now();
967
+ : replace
968
+ ? { ...replacementDefaults(entity), ...patch }
969
+ : { ...patch };
970
+ changes.updated_at = now();
628
971
  delete changes[entity.identity];
629
972
  delete changes.created_at;
630
973
  if (!defects.has("IMMUTABLE_WRITABLE")) {
631
974
  for (const parent of entity.parents)
632
975
  delete changes[parent];
633
976
  }
634
- await assertUnique(entity, { ...existing, ...changes }, itemId, defects);
635
- const updated = await store.update(entity, itemId, changes);
636
- return send(res, defects.has("RESPONSE_STATUS_UNDECLARED") ? 201 : 200, decorate(updated ?? { ...existing, ...changes }));
977
+ await assertUnique(entity, { ...existing, ...changes }, String(itemId));
978
+ const updated = defects.has("CONCURRENT_WRITE_LOST")
979
+ ? await readModifyWrite(entity, itemId, changes)
980
+ : await store.update(entity, itemId, changes);
981
+ return send(res, defects.has("RESPONSE_STATUS_UNDECLARED") ? 201 : 200, present(entity, updated ?? { ...existing, ...changes }));
637
982
  }
638
983
  if (method === "DELETE") {
639
984
  assertWrite(principal, "delete");
@@ -643,18 +988,19 @@ createStore) {
643
988
  return send(res, 200, { [entity.identity]: itemId });
644
989
  throw new HttpError(404, "not_found", `${entity.name} ${itemId} does not exist`);
645
990
  }
646
- const record = await findItem(entity, principal, itemId);
991
+ const record = await findItem(entity, principal, itemId, scope, "write");
647
992
  if (entity.softDeleteField !== undefined) {
648
993
  const updated = await store.update(entity, itemId, {
649
- [entity.softDeleteField]: store.now(),
650
- updated_at: store.now(),
994
+ [entity.softDeleteField]: now(),
995
+ updated_at: now(),
651
996
  });
652
- return send(res, 200, decorate(updated ?? record));
997
+ return send(res, 200, present(entity, updated ?? record));
653
998
  }
654
999
  await store.remove(entity, itemId);
655
- return send(res, 200, decorate(record));
1000
+ await refreshParentCount(entity, scope);
1001
+ return send(res, 200, present(entity, record));
656
1002
  }
657
- throw new HttpError(404, "not_found", `method ${method} not supported`);
1003
+ throw new HttpError(405, "method_not_allowed", `method ${method} not supported`);
658
1004
  }
659
1005
  const server = createServer((req, res) => {
660
1006
  dispatch(req, res).catch((error) => {
@@ -670,7 +1016,9 @@ createStore) {
670
1016
  });
671
1017
  }
672
1018
  if (error instanceof SqlError) {
673
- if (defects.has("ERROR_500_ON_BAD_FILTER")) {
1019
+ /* Correct behaviour is a 400. The defect lets the filter parser's own exception
1020
+ * escape, which the transport surfaces as a 500 — validation confused with crashing. */
1021
+ if (defects.has("ERROR_500_ON_BAD_FILTER") && error.code === "invalid_filter") {
674
1022
  return send(res, 500, {
675
1023
  error_key: "internal_error",
676
1024
  message: error.message,
@@ -718,7 +1066,19 @@ createStore) {
718
1066
  defects,
719
1067
  principals: PRINCIPALS,
720
1068
  server,
721
- url: `http://127.0.0.1:${address.port}`,
1069
+ snapshot: async () => {
1070
+ const out = {};
1071
+ for (const entity of entities) {
1072
+ out[entity.name] = await store.select(entity, {
1073
+ collation: "binary",
1074
+ order: [],
1075
+ shuffleTies: false,
1076
+ where: { kind: "const", value: true },
1077
+ });
1078
+ }
1079
+ return out;
1080
+ },
1081
+ url: `http://127.0.0.1:${address.port}${basePath}`,
722
1082
  });
723
1083
  });
724
1084
  });
@@ -733,10 +1093,10 @@ function send(res, status, body, extraHeaders = {}) {
733
1093
  res.end(payload);
734
1094
  }
735
1095
  /**
736
- * Rewrites a sort expression from the dialect's grammar into the canonical `field.asc` the stores
737
- * parse, so the storage layer never learns which spelling arrived.
1096
+ * Rewrites a sort expression from the dialect's grammar into the canonical `field.asc` the
1097
+ * listing parses, so the storage layer never learns which spelling arrived.
738
1098
  *
739
- * Unparseable terms are passed through untouched rather than dropped: the store rejects an
1099
+ * Unparseable terms are passed through untouched rather than dropped: the parser rejects an
740
1100
  * unknown sort field with a 400, which is the correct answer to a malformed sort and keeps a bad
741
1101
  * expression from silently becoming no sort at all.
742
1102
  */
@@ -791,32 +1151,6 @@ function readSelect(url, dialect, entity) {
791
1151
  const name = dialect.selectGrammar === "bracketed" ? `${dialect.params.select}[${entity.name}]` : dialect.params.select;
792
1152
  return url.searchParams.get(name) ?? undefined;
793
1153
  }
794
- /**
795
- * The page number a request is asking for, whichever way the dialect counts.
796
- *
797
- * The store pages by number; an API that counts rows skipped is converted using the page size in
798
- * force — the same arithmetic the caller did to produce the offset. An offset that is not a whole
799
- * multiple of the page size has no exact page number, and rounding down matches what every offset
800
- * API does anyway: return the window starting there.
801
- */
802
- function pageFrom(url, dialect, pageSize) {
803
- const read = (name) => {
804
- if (name === undefined)
805
- return undefined;
806
- const raw = url.searchParams.get(name);
807
- if (raw === null)
808
- return undefined;
809
- const parsed = Number(raw);
810
- return Number.isFinite(parsed) ? parsed : undefined;
811
- };
812
- const page = read(dialect.params.page);
813
- if (page !== undefined)
814
- return page;
815
- const offset = read(dialect.params.offset);
816
- if (offset === undefined)
817
- return undefined;
818
- return Math.floor(offset / Math.max(pageSize, 1)) + 1;
819
- }
820
1154
  /**
821
1155
  * Builds a listing response in whichever pagination model the dialect speaks.
822
1156
  *
@@ -825,17 +1159,20 @@ function pageFrom(url, dialect, pageSize) {
825
1159
  * Keeping both here means the routing code above never branches on it.
826
1160
  */
827
1161
  function paginated(dialect, entity, requestUrl, result) {
828
- if (dialect.envelope !== null) {
829
- const envelope = {
830
- [dialect.envelope.collection ?? entity.plural]: result.items,
831
- [dialect.envelope.hasMore]: result.hasMore,
832
- [dialect.envelope.limit]: result.limit,
833
- [dialect.envelope.page]: result.page,
834
- [dialect.envelope.total]: result.count,
835
- };
836
- if (dialect.envelope.nextCursor !== undefined) {
837
- envelope[dialect.envelope.nextCursor] = result.nextCursor;
838
- }
1162
+ const env = dialect.envelope;
1163
+ if (env !== null) {
1164
+ const envelope = { [env.collection ?? entity.plural]: result.items };
1165
+ /* Only the facts this API publishes. */
1166
+ if (env.hasMore !== undefined)
1167
+ envelope[env.hasMore] = result.hasMore;
1168
+ if (env.limit !== undefined)
1169
+ envelope[env.limit] = result.limit;
1170
+ if (env.page !== undefined)
1171
+ envelope[env.page] = result.page;
1172
+ if (env.total !== undefined)
1173
+ envelope[env.total] = result.count;
1174
+ if (env.nextCursor !== undefined)
1175
+ envelope[env.nextCursor] = result.nextCursor;
839
1176
  return { body: envelope, headers: {} };
840
1177
  }
841
1178
  /* Root array: the only pagination signal is the header, so a caller learns there is more by
@@ -844,8 +1181,7 @@ function paginated(dialect, entity, requestUrl, result) {
844
1181
  const offsetParam = dialect.params.offset;
845
1182
  if (result.hasMore && offsetParam !== undefined) {
846
1183
  const next = new URL(requestUrl.toString());
847
- const consumed = Number(next.searchParams.get(offsetParam) ?? 0) + result.items.length;
848
- next.searchParams.set(offsetParam, String(consumed));
1184
+ next.searchParams.set(offsetParam, String(result.offset + result.items.length));
849
1185
  links.push(`<${next.pathname}${next.search}>; rel="next"`);
850
1186
  }
851
1187
  return {
@@ -853,6 +1189,20 @@ function paginated(dialect, entity, requestUrl, result) {
853
1189
  headers: links.length > 0 ? { link: links.join(", ") } : {},
854
1190
  };
855
1191
  }
1192
+ function requireForm(req, defects) {
1193
+ if (defects.has("CONTENT_TYPE_NOT_ENFORCED"))
1194
+ return;
1195
+ const type = req.headers["content-type"];
1196
+ if (typeof type !== "string" || !type.includes("application/x-www-form-urlencoded")) {
1197
+ throw new HttpError(415, "unsupported_media_type", "expected application/x-www-form-urlencoded");
1198
+ }
1199
+ }
1200
+ async function readText(req) {
1201
+ const chunks = [];
1202
+ for await (const chunk of req)
1203
+ chunks.push(chunk);
1204
+ return Buffer.concat(chunks).toString("utf8");
1205
+ }
856
1206
  function requireJson(req, defects) {
857
1207
  if (defects.has("CONTENT_TYPE_NOT_ENFORCED"))
858
1208
  return;
@@ -878,13 +1228,13 @@ async function readJson(req) {
878
1228
  /** In-memory reference server — no dependencies, no flags, nothing to have running. */
879
1229
  export async function createMemoryServer(options = {}) {
880
1230
  const { MemoryStore } = await import("./stores/memory.js");
881
- return createReferenceServer(options, async (defects) => new MemoryStore(defects));
1231
+ return createReferenceServer(options, async () => new MemoryStore());
882
1232
  }
883
1233
  /** SQLite-backed reference server, in-process via `node:sqlite`. */
884
1234
  export async function createSqliteServer(options = {}) {
885
1235
  const { SqlStore } = await import("./stores/sqlite.js");
886
1236
  const { nodeSqliteDriver } = await import("./stores/sqlite-driver.js");
887
- return createReferenceServer(options, async (defects) => SqlStore.create(defects, await nodeSqliteDriver()));
1237
+ return createReferenceServer(options, async (defects, entities) => SqlStore.create(defects, await nodeSqliteDriver(), "", entities));
888
1238
  }
889
1239
  /**
890
1240
  * Cloudflare D1 reference server — the same SQL over the network, against someone else's build.
@@ -908,15 +1258,15 @@ export async function createD1Server(options = {}) {
908
1258
  const { SqlStore } = await import("./stores/sqlite.js");
909
1259
  const { d1Driver } = await import("./stores/sqlite-driver.js");
910
1260
  const prefix = `oat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}_`;
911
- return createReferenceServer(options, (defects) => SqlStore.create(defects, d1Driver({
1261
+ return createReferenceServer(options, (defects, entities) => SqlStore.create(defects, d1Driver({
912
1262
  accountId: accountId,
913
1263
  apiToken: apiToken,
914
1264
  databaseId: databaseId,
915
- }), prefix));
1265
+ }), prefix, entities));
916
1266
  }
917
1267
  /** Postgres-backed reference server. Imported lazily so the driver is only loaded when used. */
918
1268
  export async function createPostgresServer(options = {}) {
919
1269
  const { PgStore } = await import("./stores/postgres.js");
920
- return createReferenceServer(options, (defects) => PgStore.create(defects));
1270
+ return createReferenceServer(options, (defects, entities) => PgStore.create(defects, entities));
921
1271
  }
922
1272
  //# sourceMappingURL=http.js.map