@pramen/server 0.0.47 → 0.0.49
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 +4 -3
- package/dist/cli.js +15 -9
- package/dist/durable-object.d.ts +1 -0
- package/dist/durable-object.js +10 -5
- package/dist/index.d.ts +3 -3
- package/dist/pramen.d.ts +4 -2
- package/dist/runtime/acl.d.ts +6 -5
- package/dist/runtime/acl.js +1 -5
- package/dist/runtime/db.d.ts +3 -2
- package/dist/runtime/dispatch.d.ts +3 -1
- package/dist/runtime/driver.d.ts +8 -5
- package/dist/runtime/mail.d.ts +2 -1
- package/dist/runtime/protocol.d.ts +4 -3
- package/dist/runtime/queue-consumer.d.ts +4 -2
- package/dist/runtime/queue.d.ts +3 -2
- package/dist/runtime/read-engine.d.ts +11 -9
- package/dist/runtime/read-engine.js +4 -1
- package/dist/runtime/registry.d.ts +4 -1
- package/dist/runtime/registry.js +0 -3
- package/dist/runtime/schema-diff.d.ts +6 -6
- package/dist/runtime/schema-diff.js +4 -4
- package/dist/sdk/acl.d.ts +18 -11
- package/dist/sdk/handlers.d.ts +9 -3
- package/dist/sdk/infer.d.ts +17 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +16 -10
- package/package.json +1 -1
- package/src/auth.ts +7 -6
- package/src/cli.ts +21 -9
- package/src/durable-object.ts +15 -8
- package/src/index.ts +6 -2
- package/src/pramen.ts +4 -2
- package/src/runtime/acl.ts +37 -31
- package/src/runtime/db.ts +14 -12
- package/src/runtime/dispatch.ts +5 -3
- package/src/runtime/driver.ts +11 -7
- package/src/runtime/mail.ts +2 -1
- package/src/runtime/migrate.ts +2 -1
- package/src/runtime/outbox.ts +2 -1
- package/src/runtime/protocol.ts +5 -3
- package/src/runtime/queue-consumer.ts +4 -2
- package/src/runtime/queue.ts +4 -2
- package/src/runtime/read-engine.ts +27 -21
- package/src/runtime/registry.ts +5 -1
- package/src/runtime/schema-diff.ts +14 -14
- package/src/sdk/acl.ts +29 -14
- package/src/sdk/handlers.ts +10 -3
- package/src/sdk/infer.ts +21 -0
- package/src/worker.ts +20 -11
package/src/runtime/acl.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type ResolverDb,
|
|
16
16
|
type Role,
|
|
17
17
|
type Validator,
|
|
18
|
+
type WhereRule,
|
|
19
|
+
type WhereValue,
|
|
18
20
|
deny,
|
|
19
21
|
isAllow,
|
|
20
22
|
isDeny,
|
|
@@ -25,7 +27,8 @@ import {
|
|
|
25
27
|
} from "../sdk/acl";
|
|
26
28
|
import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
|
|
27
29
|
import { BadRequest, PramenError } from "./errors";
|
|
28
|
-
import type {
|
|
30
|
+
import type { CellValue, Row } from "../sdk/infer";
|
|
31
|
+
import type { FieldDef, RelationDef, RelationDefs, SchemaDef } from "../sdk/schema";
|
|
29
32
|
|
|
30
33
|
export class AclDenied extends PramenError {
|
|
31
34
|
constructor(
|
|
@@ -150,7 +153,7 @@ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, ent
|
|
|
150
153
|
conditional: (rule.conditionalFields ?? []).map((cf) => ({
|
|
151
154
|
// Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
|
|
152
155
|
// single-table — `allowRelations: false` rejects a relation key up front.
|
|
153
|
-
when: compileScopedWhere(cf.when as
|
|
156
|
+
when: compileScopedWhere(cf.when as WhereRule, entity, ctx, depth, false),
|
|
154
157
|
fields: cf.fields,
|
|
155
158
|
})),
|
|
156
159
|
fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
|
|
@@ -168,7 +171,7 @@ function rolesOf(identity: Identity | null): string[] {
|
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
function getPath(obj: unknown, path: string): unknown {
|
|
171
|
-
return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as
|
|
174
|
+
return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as WhereRule)[seg]), obj ?? undefined);
|
|
172
175
|
}
|
|
173
176
|
|
|
174
177
|
const UNRESOLVED = Symbol("unresolved");
|
|
@@ -177,15 +180,18 @@ const UNRESOLVED = Symbol("unresolved");
|
|
|
177
180
|
// $input marker (against the request input — a capability/by-key grant), or a
|
|
178
181
|
// $now marker (the evaluation instant). An unresolvable marker yields UNRESOLVED,
|
|
179
182
|
// which makes its rule match nothing. $now always resolves.
|
|
180
|
-
|
|
183
|
+
/** A resolved where value, or the sentinel meaning "this marker could not resolve". */
|
|
184
|
+
type ResolvedWhereValue = WhereValue | typeof UNRESOLVED;
|
|
185
|
+
|
|
186
|
+
function resolveValue(v: WhereValue, identity: Identity | null, input: unknown): ResolvedWhereValue {
|
|
181
187
|
if (isNowMarker(v)) return new Date().toISOString();
|
|
182
188
|
if (isIdentityMarker(v)) {
|
|
183
189
|
const value = getPath(identity, v.path);
|
|
184
|
-
return value === undefined ? UNRESOLVED : value;
|
|
190
|
+
return value === undefined ? UNRESOLVED : (value as WhereValue);
|
|
185
191
|
}
|
|
186
192
|
if (isInputMarker(v)) {
|
|
187
193
|
const value = getPath(input, v.path);
|
|
188
|
-
return value === undefined ? UNRESOLVED : value;
|
|
194
|
+
return value === undefined ? UNRESOLVED : (value as WhereValue);
|
|
189
195
|
}
|
|
190
196
|
return v;
|
|
191
197
|
}
|
|
@@ -199,22 +205,22 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
|
|
|
199
205
|
// whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
|
|
200
206
|
// the `public` branch for a caller whose marker can't resolve. (See the comment
|
|
201
207
|
// on `compileScopedWhere`.)
|
|
202
|
-
function resolveMarkers(rule:
|
|
203
|
-
const out:
|
|
208
|
+
function resolveMarkers(rule: WhereRule, identity: Identity | null, input: unknown): WhereRule | null {
|
|
209
|
+
const out: WhereRule = {};
|
|
204
210
|
for (const [key, v] of Object.entries(rule)) {
|
|
205
211
|
const isMarker = isIdentityMarker(v) || isInputMarker(v) || isNowMarker(v);
|
|
206
212
|
if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
|
|
207
|
-
const ops:
|
|
208
|
-
for (const [op, val] of Object.entries(v as
|
|
213
|
+
const ops: WhereRule = {};
|
|
214
|
+
for (const [op, val] of Object.entries(v as WhereRule)) {
|
|
209
215
|
if (op === "in" || op === "notIn") {
|
|
210
|
-
let arr:
|
|
216
|
+
let arr: ResolvedWhereValue;
|
|
211
217
|
if (isIdentityMarker(val) || isInputMarker(val)) {
|
|
212
218
|
arr = resolveValue(val, identity, input);
|
|
213
219
|
if (arr === UNRESOLVED) return null;
|
|
214
220
|
} else {
|
|
215
|
-
const mapped = (val as
|
|
221
|
+
const mapped = (val as WhereValue[]).map((x) => resolveValue(x, identity, input));
|
|
216
222
|
if (mapped.some((x) => x === UNRESOLVED)) return null;
|
|
217
|
-
arr = mapped;
|
|
223
|
+
arr = mapped as WhereValue[];
|
|
218
224
|
}
|
|
219
225
|
if (!Array.isArray(arr)) return null; // marker must resolve to a list
|
|
220
226
|
ops[op] = arr;
|
|
@@ -258,13 +264,13 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
|
|
|
258
264
|
* unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
|
|
259
265
|
* are skipped: they're re-scoped against THEIR own target's read scope downstream.
|
|
260
266
|
* Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
|
|
261
|
-
function assertReadableRelationWhere(where:
|
|
262
|
-
const targetRels =
|
|
267
|
+
function assertReadableRelationWhere(where: WhereRule, target: string, fields: string[], ctx: AclContext): void {
|
|
268
|
+
const targetRels: RelationDefs = ctx.schema?.[target]?.relations ?? {};
|
|
263
269
|
for (const [k, v] of Object.entries(where)) {
|
|
264
270
|
if (k === "AND" || k === "OR") {
|
|
265
|
-
for (const g of v as
|
|
271
|
+
for (const g of v as WhereRule[]) assertReadableRelationWhere(g, target, fields, ctx);
|
|
266
272
|
} else if (k === "NOT") {
|
|
267
|
-
assertReadableRelationWhere(v as
|
|
273
|
+
assertReadableRelationWhere(v as WhereRule, target, fields, ctx);
|
|
268
274
|
} else if (targetRels[k]) {
|
|
269
275
|
continue;
|
|
270
276
|
} else if (!fields.includes(k)) {
|
|
@@ -281,7 +287,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
281
287
|
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
|
|
282
288
|
throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
|
|
283
289
|
}
|
|
284
|
-
let inner = compileScopedWhere(nested as
|
|
290
|
+
let inner = compileScopedWhere(nested as WhereRule, rel.target, ctx, depth + 1);
|
|
285
291
|
|
|
286
292
|
// Security: a relation filter must respect the target's read ACL (else it leaks).
|
|
287
293
|
// Two distinct "no" outcomes, matching how the rest of the read path behaves:
|
|
@@ -297,7 +303,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
297
303
|
inner = FALSE; // can't filter through a relation you can't read
|
|
298
304
|
} else {
|
|
299
305
|
if (tScope.fields !== null) {
|
|
300
|
-
assertReadableRelationWhere(nested as
|
|
306
|
+
assertReadableRelationWhere(nested as WhereRule, rel.target, tScope.fields, ctx);
|
|
301
307
|
}
|
|
302
308
|
if (tScope.where) inner = and(inner, tScope.where);
|
|
303
309
|
}
|
|
@@ -336,7 +342,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
336
342
|
* evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
|
|
337
343
|
* clear authoring error instead of emitting a `sub` node that throws at read time. */
|
|
338
344
|
export function compileScopedWhere(
|
|
339
|
-
rule:
|
|
345
|
+
rule: WhereRule,
|
|
340
346
|
entity: string,
|
|
341
347
|
ctx: AclContext,
|
|
342
348
|
depth = 0,
|
|
@@ -344,13 +350,13 @@ export function compileScopedWhere(
|
|
|
344
350
|
): SqlExpr {
|
|
345
351
|
const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
|
|
346
352
|
const parts: SqlExpr[] = [];
|
|
347
|
-
const plain:
|
|
353
|
+
const plain: WhereRule = {};
|
|
348
354
|
for (const [k, v] of Object.entries(rule)) {
|
|
349
355
|
if (k === "AND" || k === "OR") {
|
|
350
|
-
const groups = (v as
|
|
356
|
+
const groups = (v as WhereRule[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
|
|
351
357
|
parts.push(k === "AND" ? and(...groups) : or(...groups));
|
|
352
358
|
} else if (k === "NOT") {
|
|
353
|
-
parts.push(not(compileScopedWhere(v as
|
|
359
|
+
parts.push(not(compileScopedWhere(v as WhereRule, entity, ctx, depth, allowRelations)));
|
|
354
360
|
} else if (relations[k]) {
|
|
355
361
|
if (!allowRelations) {
|
|
356
362
|
throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
|
|
@@ -420,7 +426,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
|
|
|
420
426
|
if (isDeny(rule)) continue;
|
|
421
427
|
if (isAllow(rule)) grants.push(ALLOW_GRANT);
|
|
422
428
|
else {
|
|
423
|
-
const where = compileScopedWhere((rule.where ?? {}) as
|
|
429
|
+
const where = compileScopedWhere((rule.where ?? {}) as WhereRule, entity, ctx, depth);
|
|
424
430
|
grants.push(grantOf(rule, where, entity, ctx, depth));
|
|
425
431
|
}
|
|
426
432
|
}
|
|
@@ -431,7 +437,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
|
|
|
431
437
|
* Returns null (all fields) when the base is null or a resolver grants everything. */
|
|
432
438
|
export function effectiveFields(
|
|
433
439
|
scope: Scope,
|
|
434
|
-
row:
|
|
440
|
+
row: Row,
|
|
435
441
|
identity: Identity | null,
|
|
436
442
|
): string[] | null {
|
|
437
443
|
if (scope.fields === null) return null;
|
|
@@ -448,18 +454,18 @@ export function effectiveFields(
|
|
|
448
454
|
/** Forced values + validators for a write, gathered from matched write policies.
|
|
449
455
|
* `set` values are resolved against the identity; later policies override earlier. */
|
|
450
456
|
export interface WriteRules {
|
|
451
|
-
set:
|
|
457
|
+
set: Row;
|
|
452
458
|
validators: Validator[];
|
|
453
459
|
}
|
|
454
460
|
|
|
455
461
|
export function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules {
|
|
456
|
-
const set:
|
|
462
|
+
const set: Row = {};
|
|
457
463
|
const validators: Validator[] = [];
|
|
458
464
|
for (const rule of matchedRules(ctx, entity, action)) {
|
|
459
465
|
if (isAllow(rule) || isDeny(rule)) continue;
|
|
460
466
|
if (rule.set) {
|
|
461
467
|
for (const [col, v] of Object.entries(rule.set)) {
|
|
462
|
-
set[col] = typeof v === "function" ? (v as (i: Identity | null) =>
|
|
468
|
+
set[col] = typeof v === "function" ? (v as (i: Identity | null) => CellValue)(ctx.identity) : v;
|
|
463
469
|
}
|
|
464
470
|
}
|
|
465
471
|
if (rule.validate) validators.push(rule.validate);
|
|
@@ -487,7 +493,7 @@ export function resolveRelationScope(
|
|
|
487
493
|
if (isAllow(rule) || isDeny(rule)) continue;
|
|
488
494
|
const rel = rule.relations?.[relName];
|
|
489
495
|
if (rel?.directAccess) {
|
|
490
|
-
const relWhere = rel.where ? compileScopedWhere(rel.where as
|
|
496
|
+
const relWhere = rel.where ? compileScopedWhere(rel.where as WhereRule, target, ctx, 0) : null;
|
|
491
497
|
grants.push(grantOf(rel, relWhere, target, ctx, 0));
|
|
492
498
|
}
|
|
493
499
|
}
|
|
@@ -496,9 +502,9 @@ export function resolveRelationScope(
|
|
|
496
502
|
}
|
|
497
503
|
|
|
498
504
|
/** Project a row to the permitted fields. null = all. */
|
|
499
|
-
export function projectRow(row:
|
|
505
|
+
export function projectRow(row: Row, fields: string[] | null): Row {
|
|
500
506
|
if (!fields) return row;
|
|
501
|
-
const out:
|
|
507
|
+
const out: Row = {};
|
|
502
508
|
for (const f of fields) if (f in row) out[f] = row[f];
|
|
503
509
|
return out;
|
|
504
510
|
}
|
package/src/runtime/db.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
type Scope,
|
|
25
25
|
} from "./acl";
|
|
26
26
|
import type { Validator } from "../sdk/acl";
|
|
27
|
+
import type { CellValue, Row as SharedRow } from "../sdk/infer";
|
|
28
|
+
import type { WhereRule } from "../sdk/acl";
|
|
27
29
|
import {
|
|
28
30
|
and,
|
|
29
31
|
cmp,
|
|
@@ -48,7 +50,7 @@ import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef
|
|
|
48
50
|
import { isValidUuid } from "../sdk/uuid";
|
|
49
51
|
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
|
|
50
52
|
|
|
51
|
-
type Row =
|
|
53
|
+
type Row = SharedRow;
|
|
52
54
|
type Action = "read" | "create" | "update" | "delete";
|
|
53
55
|
type Id = string | number | bigint;
|
|
54
56
|
type Selected = Partial<Record<string, true>> | undefined;
|
|
@@ -174,7 +176,7 @@ function encodeCursor(order: OrderBy[], row: Row): string {
|
|
|
174
176
|
return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
function decodeCursor(s: string):
|
|
179
|
+
function decodeCursor(s: string): CellValue[] {
|
|
178
180
|
try {
|
|
179
181
|
const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
|
|
180
182
|
if (!Array.isArray(arr)) throw new Error("not an array");
|
|
@@ -191,7 +193,7 @@ function decodeCursor(s: string): unknown[] {
|
|
|
191
193
|
// v non-null-> col > v (NULL cols excluded, they sort before)
|
|
192
194
|
// DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
|
|
193
195
|
// v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
|
|
194
|
-
function keysetCmp(o: OrderBy, value:
|
|
196
|
+
function keysetCmp(o: OrderBy, value: CellValue): SqlExpr {
|
|
195
197
|
const desc = o.dir === "desc";
|
|
196
198
|
if (value === null) return desc ? FALSE : isNull(o.column, true);
|
|
197
199
|
return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
|
|
@@ -200,7 +202,7 @@ function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
|
|
|
200
202
|
// Strictly-after predicate for a composite key: lexicographic comparison,
|
|
201
203
|
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
|
|
202
204
|
// column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
|
|
203
|
-
function keysetAfter(order: OrderBy[], values:
|
|
205
|
+
function keysetAfter(order: OrderBy[], values: CellValue[]): SqlExpr {
|
|
204
206
|
const ors: SqlExpr[] = [];
|
|
205
207
|
for (let i = 0; i < order.length; i++) {
|
|
206
208
|
const parts: SqlExpr[] = [];
|
|
@@ -433,7 +435,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
433
435
|
// Compiles the user's where relation-aware (relation keys → security-scoped
|
|
434
436
|
// subqueries), then AND-merges the entity's own ACL row scope.
|
|
435
437
|
if (userWhere) this.assertReadableWhere(from, scope, userWhere);
|
|
436
|
-
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as
|
|
438
|
+
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as WhereRule, from, this.acl) : TRUE;
|
|
437
439
|
const where = scope.where ? and(userExpr, scope.where) : userExpr;
|
|
438
440
|
// A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
|
|
439
441
|
// `sub` node over another table. Record those tables in `touched` so the live-query
|
|
@@ -473,7 +475,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
473
475
|
const relations = this.schema[from]?.relations ?? {};
|
|
474
476
|
for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
|
|
475
477
|
if (k === "AND" || k === "OR") {
|
|
476
|
-
for (const g of v as
|
|
478
|
+
for (const g of v as WhereRule[]) this.assertReadableWhere(from, scope, g);
|
|
477
479
|
} else if (k === "NOT") {
|
|
478
480
|
this.assertReadableWhere(from, scope, v);
|
|
479
481
|
} else if (relations[k]) {
|
|
@@ -706,7 +708,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
706
708
|
}
|
|
707
709
|
|
|
708
710
|
/** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
|
|
709
|
-
private encodeCell(jsonCols: Set<string>, col: string, v:
|
|
711
|
+
private encodeCell(jsonCols: Set<string>, col: string, v: CellValue): CellValue {
|
|
710
712
|
if (v != null && jsonCols.has(col)) return this.dialect.encode(JSON.stringify(v));
|
|
711
713
|
return this.dialect.encode(v);
|
|
712
714
|
}
|
|
@@ -767,7 +769,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
767
769
|
const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
|
|
768
770
|
// One IN query per relation (no N+1). Match column before projecting (which
|
|
769
771
|
// may drop the join column).
|
|
770
|
-
const fetchBy = async (col: string, values:
|
|
772
|
+
const fetchBy = async (col: string, values: CellValue[]): Promise<Array<{ key: CellValue; row: Row }>> => {
|
|
771
773
|
if (values.length === 0) return [];
|
|
772
774
|
const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
|
|
773
775
|
// Project the target to its readable columns (+ the join `col`, matched before
|
|
@@ -920,7 +922,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
920
922
|
this.runValidators(validators, p);
|
|
921
923
|
this.assertValidUuids(table, p);
|
|
922
924
|
|
|
923
|
-
const params:
|
|
925
|
+
const params: CellValue[] = [];
|
|
924
926
|
const jsonCols = new Set(this.jsonColsOf(table));
|
|
925
927
|
const assignments = cols
|
|
926
928
|
.map((c) => {
|
|
@@ -943,7 +945,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
943
945
|
this.assertInPartition(table);
|
|
944
946
|
const scope = this.scopeFor(table, "delete");
|
|
945
947
|
if (!scope.allowed) throw new AclDenied(table, "delete");
|
|
946
|
-
const params:
|
|
948
|
+
const params: CellValue[] = [this.dialect.encode(id)];
|
|
947
949
|
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
|
|
948
950
|
sql += this.scopeClause(scope.where, params);
|
|
949
951
|
sql += this.returningClause("*");
|
|
@@ -953,7 +955,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
953
955
|
}
|
|
954
956
|
|
|
955
957
|
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
|
956
|
-
async exec(sql: string, ...params:
|
|
958
|
+
async exec(sql: string, ...params: CellValue[]): Promise<Row[]> {
|
|
957
959
|
return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
|
|
958
960
|
}
|
|
959
961
|
|
|
@@ -963,7 +965,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
963
965
|
return this.dialect.returning ? ` RETURNING ${cols}` : "";
|
|
964
966
|
}
|
|
965
967
|
|
|
966
|
-
private scopeClause(where: SqlExpr | null, params:
|
|
968
|
+
private scopeClause(where: SqlExpr | null, params: CellValue[]): string {
|
|
967
969
|
if (!where) return "";
|
|
968
970
|
const compiled = compileExpr(where, this.dialect, params);
|
|
969
971
|
return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type { Files } from "../sdk/files";
|
|
|
20
20
|
import type { ResolverDb } from "../sdk/acl";
|
|
21
21
|
import type { SchemaDef } from "../sdk/schema";
|
|
22
22
|
import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
23
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
24
|
+
import type { JsonValue } from "../sdk/infer";
|
|
23
25
|
|
|
24
26
|
export interface DispatchResult {
|
|
25
27
|
readonly result: unknown;
|
|
@@ -53,10 +55,10 @@ export async function dispatch(
|
|
|
53
55
|
driver: Driver,
|
|
54
56
|
kv: Kv,
|
|
55
57
|
files: Files,
|
|
56
|
-
env:
|
|
58
|
+
env: EnvBag,
|
|
57
59
|
acl: AclContext,
|
|
58
60
|
name: string,
|
|
59
|
-
input:
|
|
61
|
+
input: JsonValue,
|
|
60
62
|
): Promise<DispatchResult> {
|
|
61
63
|
const handler = handlers[name];
|
|
62
64
|
if (!handler) throw new BadRequest(`unknown handler: ${name}`);
|
|
@@ -68,7 +70,7 @@ export async function dispatch(
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
71
|
-
let parsed = input;
|
|
73
|
+
let parsed: unknown = input;
|
|
72
74
|
if (handler.input) {
|
|
73
75
|
try {
|
|
74
76
|
parsed = handler.input(input);
|
package/src/runtime/driver.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// in a Driver, rather than rewriting the engine. Live queries remain a DO-only
|
|
12
12
|
// capability (they need a single writer + a stateful socket host).
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** A raw row exactly as the substrate returns it, before pramen's object↔JSON codec.
|
|
15
|
+
* Distinct from the decoded `Row` handlers see at the `Db` chokepoint. */
|
|
16
|
+
export type DriverRow = Record<string, SqlValue>;
|
|
17
|
+
|
|
18
|
+
import type { CellValue, SqlValue } from "../sdk/infer";
|
|
15
19
|
|
|
16
20
|
export interface Dialect {
|
|
17
21
|
/** Render an identifier (table/column), quoting as the backend requires. */
|
|
@@ -21,7 +25,7 @@ export interface Dialect {
|
|
|
21
25
|
/** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
|
|
22
26
|
readonly returning: boolean;
|
|
23
27
|
/** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
|
|
24
|
-
encode(v:
|
|
28
|
+
encode(v: CellValue): CellValue;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -66,7 +70,7 @@ export interface Driver {
|
|
|
66
70
|
readonly dialect: Dialect;
|
|
67
71
|
/** Run a parameterized statement and return the result rows (empty for writes
|
|
68
72
|
* without RETURNING). Params are already dialect-encoded by the caller. */
|
|
69
|
-
exec(sql: string, params:
|
|
73
|
+
exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
|
|
70
74
|
/** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
|
|
71
75
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
72
76
|
/** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
|
|
@@ -83,8 +87,8 @@ export class DoSqliteDriver implements Driver {
|
|
|
83
87
|
readonly dialect = sqliteDialect;
|
|
84
88
|
constructor(private readonly storage: DurableObjectStorage) {}
|
|
85
89
|
|
|
86
|
-
async exec(sql: string, params:
|
|
87
|
-
return this.storage.sql.exec(sql, ...params).toArray() as
|
|
90
|
+
async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
|
|
91
|
+
return this.storage.sql.exec(sql, ...params).toArray() as DriverRow[];
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -124,9 +128,9 @@ export class D1Driver implements Driver {
|
|
|
124
128
|
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
async exec(sql: string, params:
|
|
131
|
+
async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
|
|
128
132
|
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
129
|
-
const { results } = await stmt.all<
|
|
133
|
+
const { results } = await stmt.all<DriverRow>();
|
|
130
134
|
return results ?? [];
|
|
131
135
|
}
|
|
132
136
|
|
package/src/runtime/mail.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// in-memory — so handlers work unchanged off-platform.
|
|
12
12
|
|
|
13
13
|
import type { Kv } from "./kv";
|
|
14
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
14
15
|
|
|
15
16
|
export interface MailAddress {
|
|
16
17
|
email: string;
|
|
@@ -120,7 +121,7 @@ export class UnconfiguredMailAdapter implements MailAdapter {
|
|
|
120
121
|
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
121
122
|
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
122
123
|
* stash security emails in KV). */
|
|
123
|
-
export function createMail(env:
|
|
124
|
+
export function createMail(env: EnvBag, kv?: Kv): Mail {
|
|
124
125
|
const binding = env.EMAIL as SendEmailBinding | undefined;
|
|
125
126
|
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
126
127
|
if (binding && fromAddr) {
|
package/src/runtime/migrate.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { digest } from "./digest";
|
|
|
43
43
|
import { quoteIdent, type Driver } from "./driver";
|
|
44
44
|
import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
|
|
45
45
|
import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
|
|
46
|
+
import type { CellValue } from "../sdk/infer";
|
|
46
47
|
|
|
47
48
|
export interface MigrationReport {
|
|
48
49
|
changed: boolean;
|
|
@@ -306,7 +307,7 @@ async function rebuildTable(
|
|
|
306
307
|
// rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
|
|
307
308
|
// ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
|
|
308
309
|
// DO the ambient boot transaction (+ defer set at migrate start) already covers it.
|
|
309
|
-
const stmts: { sql: string; params:
|
|
310
|
+
const stmts: { sql: string; params: CellValue[] }[] = [];
|
|
310
311
|
// Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
|
|
311
312
|
// round-trip verbatim (they were already coerced by the original table's affinity).
|
|
312
313
|
const bareCopy = (name: string, quotedCols: string[]): string => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
|
package/src/runtime/outbox.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// task `id` as an idempotency key so they can dedupe across the rare retry.
|
|
20
20
|
|
|
21
21
|
import type { Driver } from "./driver";
|
|
22
|
+
import type { CellValue } from "../sdk/infer";
|
|
22
23
|
|
|
23
24
|
export const OUTBOX_TABLE = "_pramen_outbox";
|
|
24
25
|
|
|
@@ -34,7 +35,7 @@ function backoffMs(attempts: number): number {
|
|
|
34
35
|
return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const enc = (driver: Driver, params:
|
|
38
|
+
const enc = (driver: Driver, params: CellValue[]): CellValue[] => params.map((p) => driver.dialect.encode(p));
|
|
38
39
|
|
|
39
40
|
/** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
|
|
40
41
|
* D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
|
package/src/runtime/protocol.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// { type: "result", id, result } // reply to a one-shot call
|
|
11
11
|
// { type: "error", id, error }
|
|
12
12
|
|
|
13
|
+
import type { JsonValue } from "../sdk/infer";
|
|
14
|
+
|
|
13
15
|
export interface SubscribeMsg {
|
|
14
16
|
type: "subscribe";
|
|
15
17
|
id: string;
|
|
16
18
|
name: string;
|
|
17
|
-
input?:
|
|
19
|
+
input?: JsonValue;
|
|
18
20
|
}
|
|
19
21
|
export interface UnsubscribeMsg {
|
|
20
22
|
type: "unsubscribe";
|
|
@@ -24,7 +26,7 @@ export interface CallMsg {
|
|
|
24
26
|
type: "call";
|
|
25
27
|
id: string;
|
|
26
28
|
name: string;
|
|
27
|
-
input?:
|
|
29
|
+
input?: JsonValue;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
|
|
@@ -38,7 +40,7 @@ export type ServerMsg =
|
|
|
38
40
|
export interface Subscription {
|
|
39
41
|
id: string;
|
|
40
42
|
name: string;
|
|
41
|
-
input:
|
|
43
|
+
input: JsonValue;
|
|
42
44
|
/** Tables the query read — the coarse prefilter for which writes might matter. */
|
|
43
45
|
tables: string[];
|
|
44
46
|
/** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import type { Mail } from "./mail";
|
|
13
13
|
import type { Queue } from "./queue";
|
|
14
14
|
import type { Kv } from "./kv";
|
|
15
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
16
|
+
import type { JsonValue } from "../sdk/infer";
|
|
15
17
|
|
|
16
18
|
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
17
19
|
export interface QueueMessage<Body = unknown> {
|
|
@@ -40,7 +42,7 @@ export interface QueueBatch<Body = unknown> {
|
|
|
40
42
|
* tenant data via `ctx.callPrivileged`. */
|
|
41
43
|
export interface QueueContext {
|
|
42
44
|
/** The Worker environment (bindings + vars + secrets). */
|
|
43
|
-
readonly env:
|
|
45
|
+
readonly env: EnvBag;
|
|
44
46
|
/** Project KV (cross-tenant). */
|
|
45
47
|
readonly kv: Kv;
|
|
46
48
|
/** Send email (the notification path). */
|
|
@@ -49,7 +51,7 @@ export interface QueueContext {
|
|
|
49
51
|
readonly queue: Queue;
|
|
50
52
|
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
51
53
|
* The message body should carry the `tenant`. */
|
|
52
|
-
callPrivileged(opts: { name: string; input?:
|
|
54
|
+
callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
package/src/runtime/queue.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
|
|
20
20
|
// mirroring how ctx.mail fails closed without a transport.
|
|
21
21
|
|
|
22
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
23
|
+
|
|
22
24
|
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
23
25
|
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
24
26
|
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
@@ -131,7 +133,7 @@ export class MemoryQueueAdapter implements QueueAdapter {
|
|
|
131
133
|
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
132
134
|
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
133
135
|
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
134
|
-
export function discoverQueueBindings(env:
|
|
136
|
+
export function discoverQueueBindings(env: EnvBag): Record<string, QueueProducerBinding> {
|
|
135
137
|
const out: Record<string, QueueProducerBinding> = {};
|
|
136
138
|
for (const [name, value] of Object.entries(env)) {
|
|
137
139
|
if (
|
|
@@ -150,6 +152,6 @@ export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): R
|
|
|
150
152
|
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
151
153
|
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
152
154
|
* dev (lopata) and miniflare too. */
|
|
153
|
-
export function createQueue(env:
|
|
155
|
+
export function createQueue(env: EnvBag): Queue {
|
|
154
156
|
return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
|
|
155
157
|
}
|