@pramen/server 0.0.1
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/dist/auth.d.ts +35 -0
- package/dist/auth.js +189 -0
- package/dist/durable-object.d.ts +48 -0
- package/dist/durable-object.js +282 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/pramen.d.ts +42 -0
- package/dist/pramen.js +19 -0
- package/dist/runtime/acl.d.ts +62 -0
- package/dist/runtime/acl.js +289 -0
- package/dist/runtime/db.d.ts +139 -0
- package/dist/runtime/db.js +425 -0
- package/dist/runtime/ddl.d.ts +16 -0
- package/dist/runtime/ddl.js +64 -0
- package/dist/runtime/digest.d.ts +1 -0
- package/dist/runtime/digest.js +29 -0
- package/dist/runtime/dispatch.d.ts +12 -0
- package/dist/runtime/dispatch.js +37 -0
- package/dist/runtime/driver.d.ts +45 -0
- package/dist/runtime/driver.js +70 -0
- package/dist/runtime/errors.d.ts +34 -0
- package/dist/runtime/errors.js +43 -0
- package/dist/runtime/kv.d.ts +23 -0
- package/dist/runtime/kv.js +41 -0
- package/dist/runtime/migrate.d.ts +22 -0
- package/dist/runtime/migrate.js +158 -0
- package/dist/runtime/protocol.d.ts +40 -0
- package/dist/runtime/protocol.js +12 -0
- package/dist/runtime/read-engine.d.ts +73 -0
- package/dist/runtime/read-engine.js +219 -0
- package/dist/runtime/schema-diff.d.ts +14 -0
- package/dist/runtime/schema-diff.js +41 -0
- package/dist/runtime/storage.d.ts +74 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/acl.d.ts +130 -0
- package/dist/sdk/acl.js +55 -0
- package/dist/sdk/app.d.ts +7 -0
- package/dist/sdk/app.js +11 -0
- package/dist/sdk/files.d.ts +51 -0
- package/dist/sdk/files.js +4 -0
- package/dist/sdk/handlers.d.ts +36 -0
- package/dist/sdk/handlers.js +11 -0
- package/dist/sdk/infer.d.ts +79 -0
- package/dist/sdk/infer.js +5 -0
- package/dist/sdk/schema.d.ts +112 -0
- package/dist/sdk/schema.js +56 -0
- package/dist/worker-entry.d.ts +3 -0
- package/dist/worker-entry.js +8 -0
- package/dist/worker.d.ts +41 -0
- package/dist/worker.js +213 -0
- package/package.json +43 -0
- package/src/auth.ts +215 -0
- package/src/durable-object.ts +346 -0
- package/src/index.ts +77 -0
- package/src/pramen.ts +58 -0
- package/src/runtime/acl.ts +362 -0
- package/src/runtime/db.ts +550 -0
- package/src/runtime/ddl.ts +67 -0
- package/src/runtime/digest.ts +31 -0
- package/src/runtime/dispatch.ts +65 -0
- package/src/runtime/driver.ts +95 -0
- package/src/runtime/errors.ts +56 -0
- package/src/runtime/kv.ts +47 -0
- package/src/runtime/migrate.ts +193 -0
- package/src/runtime/protocol.ts +46 -0
- package/src/runtime/read-engine.ts +243 -0
- package/src/runtime/schema-diff.ts +57 -0
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/acl.ts +196 -0
- package/src/sdk/app.ts +25 -0
- package/src/sdk/files.ts +53 -0
- package/src/sdk/handlers.ts +65 -0
- package/src/sdk/infer.ts +105 -0
- package/src/sdk/schema.ts +122 -0
- package/src/worker-entry.ts +9 -0
- package/src/worker.ts +253 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// Db — the repository surface handed to handlers, wrapping the DO's in-process
|
|
2
|
+
// SqlStorage. This is the single ACL chokepoint — all reads go through the read
|
|
3
|
+
// engine: every find/insert/update/delete resolves a scope for the
|
|
4
|
+
// caller's identity and is denied, row-filtered, or field-projected accordingly.
|
|
5
|
+
//
|
|
6
|
+
// Generic over the app's schema S: method inputs and results are typed against
|
|
7
|
+
// the entity definitions (sdk/infer.ts). Types are erased at runtime — the body
|
|
8
|
+
// works in terms of plain strings and Rows.
|
|
9
|
+
//
|
|
10
|
+
// Every Db also records the tables it touched during one handler run (`touched`),
|
|
11
|
+
// which the live-query layer uses to decide which subscriptions to re-check.
|
|
12
|
+
// Create a fresh Db per handler run so identity and `touched` are scoped.
|
|
13
|
+
import { AclDenied, ALLOW_ALL, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
|
+
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, compileWhere, eq, inList, or, TRUE, } from "./read-engine";
|
|
15
|
+
import { BadRequest } from "./errors";
|
|
16
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
17
|
+
function normalizeOrder(orderBy) {
|
|
18
|
+
if (!orderBy)
|
|
19
|
+
return undefined;
|
|
20
|
+
return (Array.isArray(orderBy) ? orderBy : [orderBy]);
|
|
21
|
+
}
|
|
22
|
+
function encodeCursor(order, row) {
|
|
23
|
+
const vals = order.map((o) => row[o.column]);
|
|
24
|
+
return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
26
|
+
function decodeCursor(s) {
|
|
27
|
+
try {
|
|
28
|
+
const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
|
|
29
|
+
if (!Array.isArray(arr))
|
|
30
|
+
throw new Error("not an array");
|
|
31
|
+
return arr;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new BadRequest("invalid cursor");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Strictly-after predicate for a composite key: lexicographic comparison,
|
|
38
|
+
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
|
|
39
|
+
function keysetAfter(order, values) {
|
|
40
|
+
const ors = [];
|
|
41
|
+
for (let i = 0; i < order.length; i++) {
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (let j = 0; j < i; j++)
|
|
44
|
+
parts.push(eq(order[j].column, values[j]));
|
|
45
|
+
const o = order[i];
|
|
46
|
+
parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
|
|
47
|
+
ors.push(parts.length === 1 ? parts[0] : and(...parts));
|
|
48
|
+
}
|
|
49
|
+
return ors.length === 1 ? ors[0] : or(...ors);
|
|
50
|
+
}
|
|
51
|
+
export class Db {
|
|
52
|
+
driver;
|
|
53
|
+
acl;
|
|
54
|
+
schema;
|
|
55
|
+
/** Tables read or written during this Db's lifetime. */
|
|
56
|
+
touched = new Set();
|
|
57
|
+
dialect;
|
|
58
|
+
constructor(driver, acl, schema) {
|
|
59
|
+
this.driver = driver;
|
|
60
|
+
this.acl = acl;
|
|
61
|
+
this.schema = schema;
|
|
62
|
+
this.dialect = driver.dialect;
|
|
63
|
+
}
|
|
64
|
+
/** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
|
|
65
|
+
scopeFor(entity, action) {
|
|
66
|
+
if (this.acl.system)
|
|
67
|
+
return ALLOW_ALL;
|
|
68
|
+
return resolveScope(this.acl, entity, action);
|
|
69
|
+
}
|
|
70
|
+
/** Forced `set` values + validators for a write (empty in SYSTEM mode). The two
|
|
71
|
+
* halves are applied separately so the cell-level field check can run AFTER `set`
|
|
72
|
+
* (so a conditional `when` sees forced columns) but BEFORE `validate`. */
|
|
73
|
+
writeRules(entity, action) {
|
|
74
|
+
if (this.acl.system)
|
|
75
|
+
return { set: {}, validators: [] };
|
|
76
|
+
return resolveWriteRules(this.acl, entity, action);
|
|
77
|
+
}
|
|
78
|
+
/** Run write validators against the final values; a throw surfaces as a 400. */
|
|
79
|
+
runValidators(validators, values) {
|
|
80
|
+
for (const validate of validators) {
|
|
81
|
+
try {
|
|
82
|
+
validate({ identity: this.acl.identity, values });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
throw new BadRequest(e instanceof Error ? e.message : "validation failed");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Enforce field-level (incl. cell-level) write permission for one row. `setCols`
|
|
90
|
+
* are server-forced values that bypass the restriction. `evalRow` is the row the
|
|
91
|
+
* per-row grants are evaluated against (candidate on insert, post-merge on update). */
|
|
92
|
+
checkWriteFields(table, action, scope, writtenCols, evalRow, setCols) {
|
|
93
|
+
const allowed = effectiveFields(scope, evalRow, this.acl.identity);
|
|
94
|
+
if (!allowed)
|
|
95
|
+
return; // all fields permitted for this row
|
|
96
|
+
for (const c of writtenCols) {
|
|
97
|
+
if (!setCols.has(c) && !allowed.includes(c))
|
|
98
|
+
throw new AclDenied(table, action, c);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Reject ordering by a column the caller cannot read (closes an info-leak: order
|
|
102
|
+
* and the keyset cursor would otherwise expose a hidden column's values). Columns
|
|
103
|
+
* granted only conditionally are NOT orderable. */
|
|
104
|
+
assertReadableCols(from, scope, cols) {
|
|
105
|
+
if (scope.fields === null)
|
|
106
|
+
return;
|
|
107
|
+
for (const c of cols)
|
|
108
|
+
if (!scope.fields.includes(c))
|
|
109
|
+
throw new AclDenied(from, "read", c);
|
|
110
|
+
}
|
|
111
|
+
/** Structured read; ACL row-scope is AND-ed in, permitted fields projected.
|
|
112
|
+
* Selected relations are eager-loaded, each independently ACL-checked. */
|
|
113
|
+
async find(spec) {
|
|
114
|
+
const from = spec.from;
|
|
115
|
+
this.touched.add(from);
|
|
116
|
+
const scope = this.scopeFor(from, "read");
|
|
117
|
+
if (!scope.allowed)
|
|
118
|
+
throw new AclDenied(from, "read");
|
|
119
|
+
const where = this.readWhere(spec.where, scope);
|
|
120
|
+
const orderBy = normalizeOrder(spec.orderBy);
|
|
121
|
+
if (orderBy)
|
|
122
|
+
this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
|
|
123
|
+
const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
|
|
124
|
+
return (await this.finishRows(from, raw, scope, spec.with));
|
|
125
|
+
}
|
|
126
|
+
/** Cursor (keyset) pagination. Stable under inserts/deletes; the PK is appended
|
|
127
|
+
* to `orderBy` as a tiebreaker so the keyset is unique. Returns the page plus an
|
|
128
|
+
* opaque `cursor` (pass back as `after`) and whether more rows remain. */
|
|
129
|
+
async page(spec) {
|
|
130
|
+
const from = spec.from;
|
|
131
|
+
this.touched.add(from);
|
|
132
|
+
const scope = this.scopeFor(from, "read");
|
|
133
|
+
if (!scope.allowed)
|
|
134
|
+
throw new AclDenied(from, "read");
|
|
135
|
+
const order = this.orderWithPk(from, spec.orderBy);
|
|
136
|
+
this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
|
|
137
|
+
let where = this.readWhere(spec.where, scope);
|
|
138
|
+
if (spec.after != null)
|
|
139
|
+
where = and(where, keysetAfter(order, decodeCursor(spec.after)));
|
|
140
|
+
const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
|
|
141
|
+
const raw = await this.selectRaw(from, where, order, limit + 1); // +1 to detect a next page
|
|
142
|
+
const hasMore = raw.length > limit;
|
|
143
|
+
if (hasMore)
|
|
144
|
+
raw.length = limit;
|
|
145
|
+
const last = raw[raw.length - 1];
|
|
146
|
+
const cursor = last ? encodeCursor(order, last) : null; // from raw row (has all order cols)
|
|
147
|
+
const items = (await this.finishRows(from, raw, scope, spec.with));
|
|
148
|
+
return { items, cursor, hasMore };
|
|
149
|
+
}
|
|
150
|
+
/** Count rows visible to the caller (ACL read scope applied). */
|
|
151
|
+
async count(spec) {
|
|
152
|
+
const from = spec.from;
|
|
153
|
+
this.touched.add(from);
|
|
154
|
+
const scope = this.scopeFor(from, "read");
|
|
155
|
+
if (!scope.allowed)
|
|
156
|
+
throw new AclDenied(from, "read");
|
|
157
|
+
const where = this.readWhere(spec.where, scope);
|
|
158
|
+
const { sql, params } = compileCount(from, this.dialect, where);
|
|
159
|
+
const rows = (await this.driver.exec(sql, params));
|
|
160
|
+
return Number(rows[0]?.n ?? 0);
|
|
161
|
+
}
|
|
162
|
+
/** Grouped aggregation (count/sum/avg/min/max). ACL read scope is applied, and
|
|
163
|
+
* every referenced column must be readable under field permissions. The result
|
|
164
|
+
* row type is inferred from the spec: group columns keep their schema type and
|
|
165
|
+
* each aggregation gets its computed value type. */
|
|
166
|
+
async aggregate(spec) {
|
|
167
|
+
const from = spec.from;
|
|
168
|
+
this.touched.add(from);
|
|
169
|
+
const scope = this.scopeFor(from, "read");
|
|
170
|
+
if (!scope.allowed)
|
|
171
|
+
throw new AclDenied(from, "read");
|
|
172
|
+
const groupBy = (spec.groupBy ? (Array.isArray(spec.groupBy) ? spec.groupBy : [spec.groupBy]) : []);
|
|
173
|
+
// A json/fileRef cell is JSON; grouping/min/max over it would return the raw
|
|
174
|
+
// string (the codec only runs on row reads), so reject it rather than leak/lie.
|
|
175
|
+
const jsonCols = new Set(this.jsonColsOf(from));
|
|
176
|
+
for (const c of groupBy)
|
|
177
|
+
if (jsonCols.has(c))
|
|
178
|
+
throw new BadRequest(`cannot group by a json column: ${c}`);
|
|
179
|
+
for (const agg of Object.values(spec.aggregations)) {
|
|
180
|
+
if (agg.column && jsonCols.has(agg.column)) {
|
|
181
|
+
throw new BadRequest(`cannot aggregate a json column: ${agg.column}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (scope.fields) {
|
|
185
|
+
const refs = new Set(groupBy);
|
|
186
|
+
for (const agg of Object.values(spec.aggregations))
|
|
187
|
+
if (agg.column)
|
|
188
|
+
refs.add(agg.column);
|
|
189
|
+
for (const c of refs)
|
|
190
|
+
if (!scope.fields.includes(c))
|
|
191
|
+
throw new AclDenied(from, "read", c);
|
|
192
|
+
}
|
|
193
|
+
const where = this.readWhere(spec.where, scope);
|
|
194
|
+
const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
|
|
195
|
+
return (await this.driver.exec(sql, params));
|
|
196
|
+
}
|
|
197
|
+
// --- read internals shared by find/page ---
|
|
198
|
+
readWhere(userWhere, scope) {
|
|
199
|
+
const userExpr = userWhere ? compileWhere(userWhere) : TRUE;
|
|
200
|
+
return scope.where ? and(userExpr, scope.where) : userExpr;
|
|
201
|
+
}
|
|
202
|
+
async selectRaw(from, where, orderBy, limit, offset) {
|
|
203
|
+
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
|
|
204
|
+
return this.decodeRows(from, await this.driver.exec(sql, params));
|
|
205
|
+
}
|
|
206
|
+
// --- JSON codec: a `json` or `fileRef` column is stored as a JSON TEXT cell but
|
|
207
|
+
// handlers see/write the parsed value. Decode on read, encode (stringify) on write. ---
|
|
208
|
+
jsonColsOf(table) {
|
|
209
|
+
const fields = this.schema[table]?.fields;
|
|
210
|
+
if (!fields)
|
|
211
|
+
return [];
|
|
212
|
+
return Object.entries(fields)
|
|
213
|
+
.filter(([, f]) => f.type === "json" || f.type === "fileRef")
|
|
214
|
+
.map(([n]) => n);
|
|
215
|
+
}
|
|
216
|
+
decodeRows(table, rows) {
|
|
217
|
+
const cols = this.jsonColsOf(table);
|
|
218
|
+
if (cols.length === 0)
|
|
219
|
+
return rows;
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
for (const c of cols) {
|
|
222
|
+
const v = row[c];
|
|
223
|
+
if (typeof v === "string") {
|
|
224
|
+
try {
|
|
225
|
+
row[c] = JSON.parse(v);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* leave a non-JSON value as-is */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return rows;
|
|
234
|
+
}
|
|
235
|
+
decodeRow(table, row) {
|
|
236
|
+
return row ? this.decodeRows(table, [row])[0] : row;
|
|
237
|
+
}
|
|
238
|
+
/** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
|
|
239
|
+
encodeCell(jsonCols, col, v) {
|
|
240
|
+
if (v != null && jsonCols.has(col))
|
|
241
|
+
return this.dialect.encode(JSON.stringify(v));
|
|
242
|
+
return this.dialect.encode(v);
|
|
243
|
+
}
|
|
244
|
+
/** Fetch one row by id within an ACL row-scope (for per-row write evaluation). */
|
|
245
|
+
async fetchOne(from, id, scopeWhere) {
|
|
246
|
+
const where = scopeWhere ? and(eq("id", id), scopeWhere) : eq("id", id);
|
|
247
|
+
const { sql, params } = compileSelect({ from, where, limit: 1 }, this.dialect);
|
|
248
|
+
return this.decodeRow(from, (await this.driver.exec(sql, params))[0]);
|
|
249
|
+
}
|
|
250
|
+
async finishRows(from, raw, scope, withSel) {
|
|
251
|
+
const relNames = withSel ? Object.keys(withSel).filter((k) => withSel[k]) : [];
|
|
252
|
+
for (const relName of relNames)
|
|
253
|
+
await this.loadRelation(from, raw, relName);
|
|
254
|
+
if (scope.fields === null)
|
|
255
|
+
return raw; // base unrestricted -> no per-row narrowing possible
|
|
256
|
+
return raw.map((r) => {
|
|
257
|
+
const projected = projectRow(r, effectiveFields(scope, r, this.acl.identity));
|
|
258
|
+
for (const relName of relNames)
|
|
259
|
+
projected[relName] = r[relName]; // relations survive projection
|
|
260
|
+
return projected;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
orderWithPk(from, orderBy) {
|
|
264
|
+
const out = (normalizeOrder(orderBy) ?? []).map((o) => ({ column: o.column, dir: o.dir }));
|
|
265
|
+
const pk = this.pkOf(from);
|
|
266
|
+
if (!out.some((o) => o.column === pk))
|
|
267
|
+
out.push({ column: pk, dir: out[out.length - 1]?.dir ?? "asc" });
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
pkOf(from) {
|
|
271
|
+
const fields = this.schema[from]?.fields;
|
|
272
|
+
if (fields)
|
|
273
|
+
for (const [name, f] of Object.entries(fields))
|
|
274
|
+
if (f.primaryKey)
|
|
275
|
+
return name;
|
|
276
|
+
return "id";
|
|
277
|
+
}
|
|
278
|
+
/** Eager-load one relation onto `rows` (mutates them). Traversal is ACL-checked
|
|
279
|
+
* via resolveRelationScope: the related read scope OR a parent directAccess grant. */
|
|
280
|
+
async loadRelation(parentEntity, rows, relName) {
|
|
281
|
+
const rel = this.schema[parentEntity]?.relations?.[relName];
|
|
282
|
+
if (!rel)
|
|
283
|
+
throw new Error(`unknown relation: ${parentEntity}.${relName}`);
|
|
284
|
+
this.touched.add(rel.target);
|
|
285
|
+
const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
|
|
286
|
+
if (!scope.allowed)
|
|
287
|
+
throw new AclDenied(rel.target, "read");
|
|
288
|
+
const project = (row) => projectRow(row, effectiveFields(scope, row, this.acl.identity));
|
|
289
|
+
// One IN query per relation (no N+1). Match column before projecting (which
|
|
290
|
+
// may drop the join column).
|
|
291
|
+
const fetchBy = async (col, values) => {
|
|
292
|
+
if (values.length === 0)
|
|
293
|
+
return [];
|
|
294
|
+
const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
|
|
295
|
+
const { sql, params } = compileSelect({ from: rel.target, where }, this.dialect);
|
|
296
|
+
const rows = this.decodeRows(rel.target, await this.driver.exec(sql, params));
|
|
297
|
+
return rows.map((row) => ({ key: row[col], row: project(row) }));
|
|
298
|
+
};
|
|
299
|
+
if (rel.kind === "belongsTo") {
|
|
300
|
+
// parent[column] -> target.id
|
|
301
|
+
const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
|
|
302
|
+
const byId = new Map();
|
|
303
|
+
for (const { key, row } of await fetchBy("id", keys))
|
|
304
|
+
byId.set(key, row);
|
|
305
|
+
for (const r of rows)
|
|
306
|
+
r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// hasMany: target[column] -> parent.id
|
|
310
|
+
const ids = [...new Set(rows.map((r) => r.id).filter((v) => v != null))];
|
|
311
|
+
const grouped = new Map();
|
|
312
|
+
for (const { key, row } of await fetchBy(rel.column, ids)) {
|
|
313
|
+
const bucket = grouped.get(key) ?? grouped.set(key, []).get(key);
|
|
314
|
+
bucket.push(row);
|
|
315
|
+
}
|
|
316
|
+
for (const r of rows)
|
|
317
|
+
r[relName] = grouped.get(r.id) ?? [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Insert a single row, returning the persisted row. */
|
|
321
|
+
async insert(table, values) {
|
|
322
|
+
this.touched.add(table);
|
|
323
|
+
const scope = this.scopeFor(table, "create");
|
|
324
|
+
if (!scope.allowed)
|
|
325
|
+
throw new AclDenied(table, "create");
|
|
326
|
+
const vals = { ...values };
|
|
327
|
+
const { set, validators } = this.writeRules(table, "create");
|
|
328
|
+
Object.assign(vals, set); // forced server values first, so a conditional `when` can see them
|
|
329
|
+
this.checkWriteFields(table, "create", scope, Object.keys(vals), vals, new Set(Object.keys(set)));
|
|
330
|
+
this.runValidators(validators, vals);
|
|
331
|
+
const cols = Object.keys(vals);
|
|
332
|
+
const jsonCols = new Set(this.jsonColsOf(table));
|
|
333
|
+
const colList = cols.map((c) => this.dialect.id(c)).join(", ");
|
|
334
|
+
const phs = cols.map((_, i) => this.dialect.placeholder(i + 1)).join(", ");
|
|
335
|
+
const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
|
|
336
|
+
const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
|
|
337
|
+
const rows = await this.driver.exec(sql, params);
|
|
338
|
+
return this.projectWrite(table, this.decodeRow(table, rows[0]), cols);
|
|
339
|
+
}
|
|
340
|
+
/** Project a mutation's RETURNING row so the echo never reveals more than a read
|
|
341
|
+
* would: the caller's readable fields for this row, PLUS the columns they just
|
|
342
|
+
* wrote (which they already know) and the primary key (so a write-only caller
|
|
343
|
+
* still gets the generated id). Full read access -> the whole row; SYSTEM -> as-is.
|
|
344
|
+
* This makes create/update echoes field-ACL-safe without ever collapsing to {}. */
|
|
345
|
+
projectWrite(table, row, writtenCols) {
|
|
346
|
+
if (this.acl.system)
|
|
347
|
+
return row;
|
|
348
|
+
const visible = new Set([this.pkOf(table), ...writtenCols]);
|
|
349
|
+
const readScope = this.scopeFor(table, "read");
|
|
350
|
+
if (readScope.allowed) {
|
|
351
|
+
const readable = effectiveFields(readScope, row, this.acl.identity);
|
|
352
|
+
if (readable === null)
|
|
353
|
+
return row; // unrestricted read -> echo everything
|
|
354
|
+
for (const f of readable)
|
|
355
|
+
visible.add(f);
|
|
356
|
+
}
|
|
357
|
+
return projectRow(row, [...visible]);
|
|
358
|
+
}
|
|
359
|
+
/** Update a row by id. ACL row-scope is AND-ed into the WHERE, so a caller can
|
|
360
|
+
* only update rows within scope; returns undefined if none matched. */
|
|
361
|
+
async update(table, id, patch) {
|
|
362
|
+
this.touched.add(table);
|
|
363
|
+
const scope = this.scopeFor(table, "update");
|
|
364
|
+
if (!scope.allowed)
|
|
365
|
+
throw new AclDenied(table, "update");
|
|
366
|
+
const p = { ...patch };
|
|
367
|
+
const { set, validators } = this.writeRules(table, "update");
|
|
368
|
+
Object.assign(p, set); // forced server values first
|
|
369
|
+
const cols = Object.keys(p);
|
|
370
|
+
if (cols.length === 0)
|
|
371
|
+
return undefined;
|
|
372
|
+
// Per-row field permission is evaluated against the FINAL (post-merge) row, so
|
|
373
|
+
// fetch the existing row within update scope when any cell-level rule applies.
|
|
374
|
+
let evalRow = p;
|
|
375
|
+
if (scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0)) {
|
|
376
|
+
const existing = await this.fetchOne(table, id, scope.where);
|
|
377
|
+
if (!existing)
|
|
378
|
+
return undefined; // out of update scope -> no-op
|
|
379
|
+
evalRow = { ...existing, ...p };
|
|
380
|
+
}
|
|
381
|
+
this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
|
|
382
|
+
this.runValidators(validators, p);
|
|
383
|
+
const params = [];
|
|
384
|
+
const jsonCols = new Set(this.jsonColsOf(table));
|
|
385
|
+
const assignments = cols
|
|
386
|
+
.map((c) => {
|
|
387
|
+
params.push(this.encodeCell(jsonCols, c, p[c]));
|
|
388
|
+
return `${this.dialect.id(c)} = ${this.dialect.placeholder(params.length)}`;
|
|
389
|
+
})
|
|
390
|
+
.join(", ");
|
|
391
|
+
params.push(this.dialect.encode(id));
|
|
392
|
+
let sql = `UPDATE ${this.dialect.id(table)} SET ${assignments} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(params.length)}`;
|
|
393
|
+
sql += this.scopeClause(scope.where, params);
|
|
394
|
+
sql += this.returningClause("*");
|
|
395
|
+
const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
396
|
+
return (updated ? this.projectWrite(table, updated, cols) : undefined);
|
|
397
|
+
}
|
|
398
|
+
/** Delete a row by id within scope. Returns whether a row was deleted. */
|
|
399
|
+
async delete(table, id) {
|
|
400
|
+
this.touched.add(table);
|
|
401
|
+
const scope = this.scopeFor(table, "delete");
|
|
402
|
+
if (!scope.allowed)
|
|
403
|
+
throw new AclDenied(table, "delete");
|
|
404
|
+
const params = [this.dialect.encode(id)];
|
|
405
|
+
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id("id")} = ${this.dialect.placeholder(1)}`;
|
|
406
|
+
sql += this.scopeClause(scope.where, params);
|
|
407
|
+
sql += this.returningClause("id");
|
|
408
|
+
return (await this.driver.exec(sql, params)).length > 0;
|
|
409
|
+
}
|
|
410
|
+
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
|
411
|
+
async exec(sql, ...params) {
|
|
412
|
+
return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
|
|
413
|
+
}
|
|
414
|
+
// RETURNING is supported on SQLite/Postgres; a dialect without it (MySQL) would
|
|
415
|
+
// need an insert-then-select-back path — not implemented in this spike.
|
|
416
|
+
returningClause(cols) {
|
|
417
|
+
return this.dialect.returning ? ` RETURNING ${cols}` : "";
|
|
418
|
+
}
|
|
419
|
+
scopeClause(where, params) {
|
|
420
|
+
if (!where)
|
|
421
|
+
return "";
|
|
422
|
+
const compiled = compileExpr(where, this.dialect, params);
|
|
423
|
+
return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EntityFields, FieldDef } from "../sdk/schema";
|
|
2
|
+
export declare const sqlType: (f: FieldDef) => string;
|
|
3
|
+
export declare function createTableSql(table: string, def: {
|
|
4
|
+
fields: EntityFields;
|
|
5
|
+
}): string;
|
|
6
|
+
/** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
|
|
7
|
+
* NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
|
|
8
|
+
* a populated table); a DEFAULT alone backfills existing rows. */
|
|
9
|
+
export declare function addColumnSql(name: string, f: FieldDef): string;
|
|
10
|
+
/** Index name for a column's unique/index constraint. */
|
|
11
|
+
export declare function indexName(table: string, col: string): string;
|
|
12
|
+
/** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
|
|
13
|
+
* via IF NOT EXISTS). Unique wins if a column declares both. */
|
|
14
|
+
export declare function indexStatements(table: string, def: {
|
|
15
|
+
fields: EntityFields;
|
|
16
|
+
}): string[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// DDL generation — CREATE TABLE for a new entity and the additive ALTER fragment
|
|
2
|
+
// for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
|
|
3
|
+
// these are applied.
|
|
4
|
+
// SQLite has no boolean type; store as INTEGER 0/1. json + fileRef are stored as
|
|
5
|
+
// TEXT (JSON). Exported for the migrator, which compares declared column types
|
|
6
|
+
// (and CASTs on a type change).
|
|
7
|
+
export const sqlType = (f) => f.type === "boolean" ? "INTEGER" : f.type === "json" || f.type === "fileRef" ? "TEXT" : f.type.toUpperCase();
|
|
8
|
+
/** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1. */
|
|
9
|
+
function defaultLiteral(v) {
|
|
10
|
+
if (v === null)
|
|
11
|
+
return "NULL";
|
|
12
|
+
if (typeof v === "boolean")
|
|
13
|
+
return v ? "1" : "0";
|
|
14
|
+
if (typeof v === "number")
|
|
15
|
+
return String(v);
|
|
16
|
+
return `'${v.replace(/'/g, "''")}'`;
|
|
17
|
+
}
|
|
18
|
+
/** The ` DEFAULT x` fragment for a column, or "" when it has no default. UNIQUE/
|
|
19
|
+
* index are NOT inline — they're emitted as separate index statements so the same
|
|
20
|
+
* code path serves both CREATE TABLE and ALTER TABLE ADD COLUMN. */
|
|
21
|
+
function defaultSql(f) {
|
|
22
|
+
return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
|
|
23
|
+
}
|
|
24
|
+
function columnSql(name, f) {
|
|
25
|
+
let s = `${name} ${sqlType(f)}`;
|
|
26
|
+
if (f.primaryKey)
|
|
27
|
+
s += " PRIMARY KEY";
|
|
28
|
+
if (f.autoIncrement)
|
|
29
|
+
s += " AUTOINCREMENT";
|
|
30
|
+
if (f.notNull && !f.primaryKey)
|
|
31
|
+
s += " NOT NULL";
|
|
32
|
+
s += defaultSql(f);
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
export function createTableSql(table, def) {
|
|
36
|
+
const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
|
|
37
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
|
|
38
|
+
}
|
|
39
|
+
/** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
|
|
40
|
+
* NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
|
|
41
|
+
* a populated table); a DEFAULT alone backfills existing rows. */
|
|
42
|
+
export function addColumnSql(name, f) {
|
|
43
|
+
let s = `${name} ${sqlType(f)}`;
|
|
44
|
+
if (f.notNull && f.default !== undefined)
|
|
45
|
+
s += " NOT NULL";
|
|
46
|
+
s += defaultSql(f);
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
/** Index name for a column's unique/index constraint. */
|
|
50
|
+
export function indexName(table, col) {
|
|
51
|
+
return `pramen_idx_${table}_${col}`;
|
|
52
|
+
}
|
|
53
|
+
/** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
|
|
54
|
+
* via IF NOT EXISTS). Unique wins if a column declares both. */
|
|
55
|
+
export function indexStatements(table, def) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [col, f] of Object.entries(def.fields)) {
|
|
58
|
+
if (!f.unique && !f.index)
|
|
59
|
+
continue;
|
|
60
|
+
const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
|
|
61
|
+
out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function digest(result: unknown): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Stable digest of a query result, for row-level change detection. Two results
|
|
2
|
+
// that are deeply equal (modulo object key order) hash the same, so a mutation
|
|
3
|
+
// that doesn't change a given subscription's visible rows produces no push.
|
|
4
|
+
//
|
|
5
|
+
// Re-running the query is cheap (in-process SQLite); the digest gates the
|
|
6
|
+
// expensive part — the network push and the client re-render.
|
|
7
|
+
function canonical(v) {
|
|
8
|
+
if (v === null || typeof v !== "object")
|
|
9
|
+
return JSON.stringify(v) ?? "null";
|
|
10
|
+
if (Array.isArray(v))
|
|
11
|
+
return "[" + v.map(canonical).join(",") + "]";
|
|
12
|
+
const obj = v;
|
|
13
|
+
return ("{" +
|
|
14
|
+
Object.keys(obj)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((k) => JSON.stringify(k) + ":" + canonical(obj[k]))
|
|
17
|
+
.join(",") +
|
|
18
|
+
"}");
|
|
19
|
+
}
|
|
20
|
+
export function digest(result) {
|
|
21
|
+
const s = canonical(result);
|
|
22
|
+
// FNV-1a (32-bit).
|
|
23
|
+
let h = 0x811c9dc5;
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
h ^= s.charCodeAt(i);
|
|
26
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
return h.toString(16);
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AclContext } from "./acl";
|
|
2
|
+
import type { Driver } from "./driver";
|
|
3
|
+
import type { Kv } from "./kv";
|
|
4
|
+
import type { Files } from "../sdk/files";
|
|
5
|
+
import type { SchemaDef } from "../sdk/schema";
|
|
6
|
+
import type { HandlerKind, HandlerMap } from "../sdk/handlers";
|
|
7
|
+
export interface DispatchResult {
|
|
8
|
+
readonly result: unknown;
|
|
9
|
+
readonly kind: HandlerKind;
|
|
10
|
+
readonly touched: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function dispatch(handlers: HandlerMap, schema: SchemaDef, driver: Driver, kv: Kv, files: Files, env: Readonly<Record<string, unknown>>, acl: AclContext, name: string, input: unknown): Promise<DispatchResult>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Dispatch — resolves a handler by name and runs it with a fresh, ACL-scoped Db.
|
|
2
|
+
// Mutations run inside storage.transaction(), which commits on success and rolls
|
|
3
|
+
// back on throw — the platform-correct way to auto-wrap mutations in BEGIN/COMMIT.
|
|
4
|
+
// (DO SQLite rejects raw BEGIN/COMMIT because it does atomic write coalescing under
|
|
5
|
+
// this API.) Single-writer serialization is free:
|
|
6
|
+
// a Durable Object processes one request at a time.
|
|
7
|
+
//
|
|
8
|
+
// The result reports `touched` (tables the run read or wrote) so the live-query
|
|
9
|
+
// layer can match a mutation's writes against each subscription's reads.
|
|
10
|
+
import { Db } from "./db";
|
|
11
|
+
import { warmup } from "./acl";
|
|
12
|
+
import { BadRequest } from "./errors";
|
|
13
|
+
export async function dispatch(handlers, schema, driver, kv, files, env, acl, name, input) {
|
|
14
|
+
const handler = handlers[name];
|
|
15
|
+
if (!handler)
|
|
16
|
+
throw new BadRequest(`unknown handler: ${name}`);
|
|
17
|
+
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
18
|
+
let parsed = input;
|
|
19
|
+
if (handler.input) {
|
|
20
|
+
try {
|
|
21
|
+
parsed = handler.input(input);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new BadRequest(e instanceof Error ? e.message : "invalid input");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
|
|
28
|
+
// (separate from the handler's db, so its reads don't pollute `touched`).
|
|
29
|
+
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
|
|
30
|
+
const resolved = await warmup(acl.acl, acl.identity, systemDb);
|
|
31
|
+
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
|
|
32
|
+
const ctx = { db, kv, files, env, identity: acl.identity };
|
|
33
|
+
const result = handler.kind === "query"
|
|
34
|
+
? await handler.run(ctx, parsed)
|
|
35
|
+
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
36
|
+
return { result, kind: handler.kind, touched: [...db.touched] };
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type Row = Record<string, unknown>;
|
|
2
|
+
export interface Dialect {
|
|
3
|
+
/** Render an identifier (table/column), quoting as the backend requires. */
|
|
4
|
+
id(name: string): string;
|
|
5
|
+
/** Bind placeholder for the `n`-th parameter (1-based): `?` (SQLite/MySQL) or `$n` (Postgres). */
|
|
6
|
+
placeholder(n: number): string;
|
|
7
|
+
/** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
|
|
8
|
+
readonly returning: boolean;
|
|
9
|
+
/** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
|
|
10
|
+
encode(v: unknown): unknown;
|
|
11
|
+
}
|
|
12
|
+
/** SQLite (DO SQLite and D1 both speak this). Bare identifiers, `?` placeholders,
|
|
13
|
+
* booleans stored as INTEGER 0/1, RETURNING supported. */
|
|
14
|
+
export declare const sqliteDialect: Dialect;
|
|
15
|
+
/** Postgres (e.g. over Hyperdrive). Double-quoted identifiers preserve case (so
|
|
16
|
+
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
17
|
+
* RETURNING supported. */
|
|
18
|
+
export declare const postgresDialect: Dialect;
|
|
19
|
+
export interface Driver {
|
|
20
|
+
readonly dialect: Dialect;
|
|
21
|
+
/** Run a parameterized statement and return the result rows (empty for writes
|
|
22
|
+
* without RETURNING). Params are already dialect-encoded by the caller. */
|
|
23
|
+
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
24
|
+
/** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
|
|
25
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
26
|
+
}
|
|
27
|
+
/** DO SQLite — the in-process store. `SqlStorage` is synchronous; we wrap it as an
|
|
28
|
+
* async Driver. Transactions use the DO's atomic `transaction()`. */
|
|
29
|
+
export declare class DoSqliteDriver implements Driver {
|
|
30
|
+
private readonly storage;
|
|
31
|
+
readonly dialect: Dialect;
|
|
32
|
+
constructor(storage: DurableObjectStorage);
|
|
33
|
+
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
34
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
/** D1 — SQLite over RPC. Async by nature. D1 has no interactive transactions, so
|
|
37
|
+
* `transaction()` runs `fn` without one (a documented limitation: mutations don't
|
|
38
|
+
* roll back on throw the way they do on a DO). Use a DO when you need that. */
|
|
39
|
+
export declare class D1Driver implements Driver {
|
|
40
|
+
private readonly db;
|
|
41
|
+
readonly dialect: Dialect;
|
|
42
|
+
constructor(db: D1Database);
|
|
43
|
+
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
44
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
45
|
+
}
|