@lovrozagar/oat 0.10.0 → 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.
- package/README.md +25 -2006
- package/dist/cli.js +283 -59
- package/dist/config/define-config.d.ts +8 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +86 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/conformance/effects.d.ts +9 -0
- package/dist/conformance/effects.d.ts.map +1 -0
- package/dist/conformance/effects.js +276 -0
- package/dist/conformance/effects.js.map +1 -0
- package/dist/conformance/expected-failures.d.ts +18 -0
- package/dist/conformance/expected-failures.d.ts.map +1 -0
- package/dist/conformance/expected-failures.js +48 -0
- package/dist/conformance/expected-failures.js.map +1 -0
- package/dist/conformance/feature-gate.d.ts +7 -0
- package/dist/conformance/feature-gate.d.ts.map +1 -0
- package/dist/conformance/feature-gate.js +428 -0
- package/dist/conformance/feature-gate.js.map +1 -0
- package/dist/conformance/fuzz.d.ts +108 -0
- package/dist/conformance/fuzz.d.ts.map +1 -0
- package/dist/conformance/fuzz.js +246 -0
- package/dist/conformance/fuzz.js.map +1 -0
- package/dist/conformance/leaks.d.ts +10 -0
- package/dist/conformance/leaks.d.ts.map +1 -0
- package/dist/conformance/leaks.js +23 -0
- package/dist/conformance/leaks.js.map +1 -0
- package/dist/conformance/pool.d.ts +49 -0
- package/dist/conformance/pool.d.ts.map +1 -0
- package/dist/conformance/pool.js +75 -0
- package/dist/conformance/pool.js.map +1 -0
- package/dist/conformance/rate-limit.d.ts +7 -0
- package/dist/conformance/rate-limit.d.ts.map +1 -0
- package/dist/conformance/rate-limit.js +372 -0
- package/dist/conformance/rate-limit.js.map +1 -0
- package/dist/conformance/scope.d.ts +15 -0
- package/dist/conformance/scope.d.ts.map +1 -0
- package/dist/conformance/scope.js +78 -0
- package/dist/conformance/scope.js.map +1 -0
- package/dist/conformance/seed-contract.d.ts +11 -0
- package/dist/conformance/seed-contract.d.ts.map +1 -0
- package/dist/conformance/seed-contract.js +680 -0
- package/dist/conformance/seed-contract.js.map +1 -0
- package/dist/conformance/shapes.d.ts +35 -0
- package/dist/conformance/shapes.d.ts.map +1 -0
- package/dist/conformance/shapes.js +162 -0
- package/dist/conformance/shapes.js.map +1 -0
- package/dist/conformance/specs.d.ts +21 -0
- package/dist/conformance/specs.d.ts.map +1 -0
- package/dist/conformance/specs.js +306 -0
- package/dist/conformance/specs.js.map +1 -0
- package/dist/conformance/suite.d.ts +209 -0
- package/dist/conformance/suite.d.ts.map +1 -0
- package/dist/conformance/suite.js +1538 -0
- package/dist/conformance/suite.js.map +1 -0
- package/dist/conformance/unique.d.ts +7 -0
- package/dist/conformance/unique.d.ts.map +1 -0
- package/dist/conformance/unique.js +586 -0
- package/dist/conformance/unique.js.map +1 -0
- package/dist/conformance/worker.d.ts +3 -0
- package/dist/conformance/worker.d.ts.map +1 -0
- package/dist/conformance/worker.js +27 -0
- package/dist/conformance/worker.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/reference/defects.d.ts +71 -1
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +74 -8
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/dialect.d.ts +6 -4
- package/dist/reference/dialect.d.ts.map +1 -1
- package/dist/reference/dialect.js +1 -0
- package/dist/reference/dialect.js.map +1 -1
- package/dist/reference/http.d.ts +17 -25
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +574 -224
- package/dist/reference/http.js.map +1 -1
- package/dist/reference/listing.d.ts +51 -0
- package/dist/reference/listing.d.ts.map +1 -0
- package/dist/reference/listing.js +256 -0
- package/dist/reference/listing.js.map +1 -0
- package/dist/reference/model.d.ts +35 -1
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +42 -0
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts +132 -43
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +397 -315
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/shapes.d.ts +60 -0
- package/dist/reference/shapes.d.ts.map +1 -0
- package/dist/reference/shapes.js +142 -0
- package/dist/reference/shapes.js.map +1 -0
- package/dist/reference/spec.d.ts +9 -2
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +291 -71
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts +14 -43
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +8 -58
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts +12 -15
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +22 -70
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts +11 -23
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +53 -334
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sql-compile.d.ts +36 -0
- package/dist/reference/stores/sql-compile.d.ts.map +1 -0
- package/dist/reference/stores/sql-compile.js +95 -0
- package/dist/reference/stores/sql-compile.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +12 -27
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +54 -355
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +23 -9
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +2 -0
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +63 -10
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +10 -1
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +75 -13
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/async.d.ts +16 -3
- package/dist/runtime/async.d.ts.map +1 -1
- package/dist/runtime/async.js +24 -19
- package/dist/runtime/async.js.map +1 -1
- package/dist/runtime/auth.d.ts +2 -9
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +38 -62
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/body.d.ts +15 -3
- package/dist/runtime/body.d.ts.map +1 -1
- package/dist/runtime/body.js +61 -12
- package/dist/runtime/body.js.map +1 -1
- package/dist/runtime/check-costs.d.ts +15 -0
- package/dist/runtime/check-costs.d.ts.map +1 -0
- package/dist/runtime/check-costs.js +114 -0
- package/dist/runtime/check-costs.js.map +1 -0
- package/dist/runtime/check-ids.d.ts +15 -0
- package/dist/runtime/check-ids.d.ts.map +1 -0
- package/dist/runtime/check-ids.js +140 -0
- package/dist/runtime/check-ids.js.map +1 -0
- package/dist/runtime/checks.d.ts +83 -20
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +3285 -1947
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +112 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +271 -39
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/cookies.d.ts +16 -2
- package/dist/runtime/cookies.d.ts.map +1 -1
- package/dist/runtime/cookies.js +83 -22
- package/dist/runtime/cookies.js.map +1 -1
- package/dist/runtime/exchanges.d.ts +4 -5
- package/dist/runtime/exchanges.d.ts.map +1 -1
- package/dist/runtime/exchanges.js +33 -55
- package/dist/runtime/exchanges.js.map +1 -1
- package/dist/runtime/exit.d.ts +40 -0
- package/dist/runtime/exit.d.ts.map +1 -0
- package/dist/runtime/exit.js +48 -0
- package/dist/runtime/exit.js.map +1 -0
- package/dist/runtime/feature-gate.d.ts +0 -9
- package/dist/runtime/feature-gate.d.ts.map +1 -1
- package/dist/runtime/feature-gate.js +0 -21
- package/dist/runtime/feature-gate.js.map +1 -1
- package/dist/runtime/finding.d.ts +49 -8
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js +51 -8
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/fixture.d.ts +35 -28
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js +92 -731
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/generate.d.ts +63 -0
- package/dist/runtime/generate.d.ts.map +1 -0
- package/dist/runtime/generate.js +635 -0
- package/dist/runtime/generate.js.map +1 -0
- package/dist/runtime/headers.d.ts +6 -0
- package/dist/runtime/headers.d.ts.map +1 -0
- package/dist/runtime/headers.js +16 -0
- package/dist/runtime/headers.js.map +1 -0
- package/dist/runtime/path.d.ts +7 -0
- package/dist/runtime/path.d.ts.map +1 -0
- package/dist/runtime/path.js +44 -0
- package/dist/runtime/path.js.map +1 -0
- package/dist/runtime/payloads.d.ts +2 -0
- package/dist/runtime/payloads.d.ts.map +1 -1
- package/dist/runtime/payloads.js +2 -1
- package/dist/runtime/payloads.js.map +1 -1
- package/dist/runtime/rate-limit.d.ts +6 -4
- package/dist/runtime/rate-limit.d.ts.map +1 -1
- package/dist/runtime/rate-limit.js +22 -33
- package/dist/runtime/rate-limit.js.map +1 -1
- package/dist/runtime/redact.d.ts +24 -0
- package/dist/runtime/redact.d.ts.map +1 -0
- package/dist/runtime/redact.js +98 -0
- package/dist/runtime/redact.js.map +1 -0
- package/dist/runtime/run.d.ts +34 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +346 -138
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runs.d.ts +5 -0
- package/dist/runtime/runs.d.ts.map +1 -1
- package/dist/runtime/runs.js +7 -1
- package/dist/runtime/runs.js.map +1 -1
- package/dist/runtime/teardown.d.ts +17 -4
- package/dist/runtime/teardown.d.ts.map +1 -1
- package/dist/runtime/teardown.js +49 -16
- package/dist/runtime/teardown.js.map +1 -1
- package/dist/runtime/transcript.d.ts +34 -2
- package/dist/runtime/transcript.d.ts.map +1 -1
- package/dist/runtime/transcript.js +119 -24
- package/dist/runtime/transcript.js.map +1 -1
- package/dist/runtime/unique.d.ts +2 -11
- package/dist/runtime/unique.d.ts.map +1 -1
- package/dist/runtime/unique.js +27 -53
- package/dist/runtime/unique.js.map +1 -1
- package/dist/runtime/validate.d.ts +13 -2
- package/dist/runtime/validate.d.ts.map +1 -1
- package/dist/runtime/validate.js +51 -31
- package/dist/runtime/validate.js.map +1 -1
- package/dist/runtime/wait.d.ts +2 -1
- package/dist/runtime/wait.d.ts.map +1 -1
- package/dist/runtime/wait.js +2 -36
- package/dist/runtime/wait.js.map +1 -1
- package/dist/runtime/world.d.ts +24 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +101 -26
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts +8 -2
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +47 -7
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/extensions.d.ts +5 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +9 -2
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +46 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +175 -11
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts +25 -5
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +160 -59
- package/dist/spec/load.js.map +1 -1
- package/dist/spec/query-capabilities.d.ts +4 -0
- package/dist/spec/query-capabilities.d.ts.map +1 -1
- package/dist/spec/query-capabilities.js +6 -0
- package/dist/spec/query-capabilities.js.map +1 -1
- package/dist/spec/schema.d.ts +20 -0
- package/dist/spec/schema.d.ts.map +1 -0
- package/dist/spec/schema.js +118 -0
- package/dist/spec/schema.js.map +1 -0
- package/dist/spec/tag-schema.d.ts +12 -0
- package/dist/spec/tag-schema.d.ts.map +1 -0
- package/dist/spec/tag-schema.js +143 -0
- package/dist/spec/tag-schema.js.map +1 -0
- package/dist/spec/types.js +17 -1
- package/dist/spec/types.js.map +1 -1
- package/docs/api.md +59 -0
- package/docs/assumptions.md +99 -0
- package/docs/checks.md +183 -0
- package/docs/commands.md +214 -0
- package/docs/configuration.md +650 -0
- package/docs/model.md +184 -0
- package/docs/reference-defects.md +94 -0
- package/docs/reports.md +211 -0
- package/docs/tags.md +400 -0
- package/package.json +3 -1
package/dist/reference/http.js
CHANGED
|
@@ -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 {
|
|
22
|
-
import { DIALECTS,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
157
|
-
const
|
|
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 =
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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] =
|
|
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 =
|
|
277
|
-
record.updated_at =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
350
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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:
|
|
481
|
-
items
|
|
482
|
-
limit:
|
|
824
|
+
hasMore: window.offset + items.length < frozen.length,
|
|
825
|
+
items,
|
|
826
|
+
limit: window.limit,
|
|
483
827
|
nextCursor: null,
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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:
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
929
|
+
await assertUnique(entity, record, undefined);
|
|
589
930
|
const created = await store.insert(entity, record);
|
|
590
|
-
|
|
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,
|
|
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,
|
|
944
|
+
const record = await findItem(entity, principal, itemId, scope);
|
|
945
|
+
return send(res, 200, present(entity, record));
|
|
607
946
|
}
|
|
608
|
-
if (method ===
|
|
947
|
+
if (method === updateMethod) {
|
|
609
948
|
assertWrite(principal, "update");
|
|
610
949
|
requireJson(req, defects);
|
|
611
|
-
const existing = await findItem(entity, principal, itemId);
|
|
612
|
-
const
|
|
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
|
-
:
|
|
627
|
-
|
|
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
|
|
635
|
-
const updated =
|
|
636
|
-
|
|
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]:
|
|
650
|
-
updated_at:
|
|
994
|
+
[entity.softDeleteField]: now(),
|
|
995
|
+
updated_at: now(),
|
|
651
996
|
});
|
|
652
|
-
return send(res, 200,
|
|
997
|
+
return send(res, 200, present(entity, updated ?? record));
|
|
653
998
|
}
|
|
654
999
|
await store.remove(entity, itemId);
|
|
655
|
-
|
|
1000
|
+
await refreshParentCount(entity, scope);
|
|
1001
|
+
return send(res, 200, present(entity, record));
|
|
656
1002
|
}
|
|
657
|
-
throw new HttpError(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
737
|
-
*
|
|
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
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
[
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (
|
|
837
|
-
envelope[
|
|
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
|
-
|
|
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 (
|
|
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
|