@pramen/server 0.0.13 → 0.0.15
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 +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +121 -55
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +28 -8
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/src/index.ts
CHANGED
|
@@ -76,6 +76,12 @@ export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
|
76
76
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
77
77
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
78
78
|
|
|
79
|
+
// --- queue (ctx.queue — Cloudflare Queues) ---
|
|
80
|
+
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
81
|
+
export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
|
|
82
|
+
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
83
|
+
export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
|
|
84
|
+
|
|
79
85
|
// --- errors ---
|
|
80
86
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
81
87
|
|
package/src/pramen.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { makeWorker, type Env } from "./worker";
|
|
|
16
16
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
17
17
|
import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
|
|
18
18
|
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
19
|
+
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
19
20
|
import type { Role } from "./sdk/acl";
|
|
20
21
|
|
|
21
22
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
@@ -48,6 +49,10 @@ export interface PramenApp {
|
|
|
48
49
|
/** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
|
|
49
50
|
* mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
|
|
50
51
|
tasks?: AppTaskMap;
|
|
52
|
+
/** Cloudflare Queues consumers keyed by queue name — process messages produced via
|
|
53
|
+
* `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
|
|
54
|
+
* Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
|
|
55
|
+
queues?: AppQueueMap;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export type { Env, DoEnv };
|
|
@@ -58,9 +63,10 @@ export type { Env, DoEnv };
|
|
|
58
63
|
export function createPramen(app: PramenApp): {
|
|
59
64
|
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
60
65
|
scheduled: (event: unknown, env: Env) => Promise<void>;
|
|
66
|
+
queue: (batch: QueueBatch, env: Env) => Promise<void>;
|
|
61
67
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
62
68
|
} {
|
|
63
69
|
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
64
70
|
const worker = makeWorker(app);
|
|
65
|
-
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
71
|
+
return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
|
|
66
72
|
}
|
package/src/runtime/acl.ts
CHANGED
|
@@ -225,6 +225,12 @@ function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null
|
|
|
225
225
|
} else {
|
|
226
226
|
const rv = resolveValue(v, identity, input);
|
|
227
227
|
if (rv === UNRESOLVED) return null;
|
|
228
|
+
// A bare-value marker must only ever produce an EQUALITY comparison. If it
|
|
229
|
+
// resolves to a caller-controlled non-primitive (object/array), compileWhere
|
|
230
|
+
// would read it as an operator predicate (`{"gte":""}` → full-table
|
|
231
|
+
// enumeration), defeating the intended equality. Treat that as unresolvable so
|
|
232
|
+
// the branch matches nothing (the safe-deny path). Primitives resolve as today.
|
|
233
|
+
if (isMarker && rv !== null && typeof rv === "object") return null;
|
|
228
234
|
out[key] = rv;
|
|
229
235
|
}
|
|
230
236
|
}
|
|
@@ -244,6 +250,24 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
|
|
|
244
250
|
return "id";
|
|
245
251
|
}
|
|
246
252
|
|
|
253
|
+
/** Reject a relation `where` that filters the target on a column it can't read —
|
|
254
|
+
* anywhere in the clause, including inside nested AND/OR groups (else a hidden/
|
|
255
|
+
* unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
|
|
256
|
+
* are skipped: they're re-scoped against THEIR own target's read scope downstream.
|
|
257
|
+
* Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
|
|
258
|
+
function assertReadableRelationWhere(where: Record<string, unknown>, target: string, fields: string[], ctx: AclContext): void {
|
|
259
|
+
const targetRels = (ctx.schema?.[target]?.relations ?? {}) as Record<string, unknown>;
|
|
260
|
+
for (const [k, v] of Object.entries(where)) {
|
|
261
|
+
if (k === "AND" || k === "OR") {
|
|
262
|
+
for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
|
|
263
|
+
} else if (targetRels[k]) {
|
|
264
|
+
continue;
|
|
265
|
+
} else if (!fields.includes(k)) {
|
|
266
|
+
throw new AclDenied(target, "read", k);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
247
271
|
/** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
|
|
248
272
|
* related entity's read scope (and rejecting filters on fields it can't read) so
|
|
249
273
|
* traversal can never widen access beyond a direct read of the target. */
|
|
@@ -268,11 +292,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
268
292
|
inner = FALSE; // can't filter through a relation you can't read
|
|
269
293
|
} else {
|
|
270
294
|
if (tScope.fields !== null) {
|
|
271
|
-
|
|
272
|
-
for (const k of Object.keys(nested as Record<string, unknown>)) {
|
|
273
|
-
if (k === "AND" || k === "OR" || targetRels[k]) continue;
|
|
274
|
-
if (!tScope.fields.includes(k)) throw new AclDenied(rel.target, "read", k);
|
|
275
|
-
}
|
|
295
|
+
assertReadableRelationWhere(nested as Record<string, unknown>, rel.target, tScope.fields, ctx);
|
|
276
296
|
}
|
|
277
297
|
if (tScope.where) inner = and(inner, tScope.where);
|
|
278
298
|
}
|
package/src/runtime/db.ts
CHANGED
|
@@ -32,7 +32,9 @@ import {
|
|
|
32
32
|
compileExpr,
|
|
33
33
|
compileSelect,
|
|
34
34
|
eq,
|
|
35
|
+
FALSE,
|
|
35
36
|
inList,
|
|
37
|
+
isNull,
|
|
36
38
|
or,
|
|
37
39
|
TRUE,
|
|
38
40
|
type AggFn,
|
|
@@ -147,15 +149,28 @@ function decodeCursor(s: string): unknown[] {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// Strictly-after predicate for a single order column, NULL-aware. SQLite sorts
|
|
153
|
+
// NULLs FIRST for ASC and LAST for DESC, so a naive `col > v` (which is NULL, never
|
|
154
|
+
// TRUE, when col or v is NULL) would re-match already-seen NULL rows forever.
|
|
155
|
+
// ASC: v null -> any non-null row is after it (col IS NOT NULL)
|
|
156
|
+
// v non-null-> col > v (NULL cols excluded, they sort before)
|
|
157
|
+
// DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
|
|
158
|
+
// v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
|
|
159
|
+
function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
|
|
160
|
+
const desc = o.dir === "desc";
|
|
161
|
+
if (value === null) return desc ? FALSE : isNull(o.column, true);
|
|
162
|
+
return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
|
|
163
|
+
}
|
|
164
|
+
|
|
150
165
|
// Strictly-after predicate for a composite key: lexicographic comparison,
|
|
151
|
-
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
|
|
166
|
+
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
|
|
167
|
+
// column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
|
|
152
168
|
function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
|
|
153
169
|
const ors: SqlExpr[] = [];
|
|
154
170
|
for (let i = 0; i < order.length; i++) {
|
|
155
171
|
const parts: SqlExpr[] = [];
|
|
156
172
|
for (let j = 0; j < i; j++) parts.push(eq(order[j]!.column, values[j]));
|
|
157
|
-
|
|
158
|
-
parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
|
|
173
|
+
parts.push(keysetCmp(order[i]!, values[i]));
|
|
159
174
|
ors.push(parts.length === 1 ? parts[0]! : and(...parts));
|
|
160
175
|
}
|
|
161
176
|
return ors.length === 1 ? ors[0]! : or(...ors);
|
|
@@ -247,6 +262,10 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
247
262
|
* and the keyset cursor would otherwise expose a hidden column's values). Columns
|
|
248
263
|
* granted only conditionally are NOT orderable. */
|
|
249
264
|
private assertReadableCols(from: string, scope: Scope, cols: string[]): void {
|
|
265
|
+
// Hidden columns are never readable through the ORM — not orderable either,
|
|
266
|
+
// regardless of scope (else the order/keyset cursor leaks the hidden value).
|
|
267
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
268
|
+
for (const c of cols) if (hidden.has(c)) throw new AclDenied(from, "read", c);
|
|
250
269
|
if (scope.fields === null) return;
|
|
251
270
|
for (const c of cols) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
|
|
252
271
|
}
|
|
@@ -344,6 +363,15 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
344
363
|
}
|
|
345
364
|
}
|
|
346
365
|
|
|
366
|
+
// Hidden columns are never readable through the ORM — reject group-by / aggregating
|
|
367
|
+
// them UNCONDITIONALLY (independent of scope.fields, which is null under full/SYSTEM
|
|
368
|
+
// read), else min/max/groupBy over a hidden column exposes its values.
|
|
369
|
+
const hiddenCols = new Set(this.hiddenColsOf(from));
|
|
370
|
+
for (const c of groupBy) if (hiddenCols.has(c)) throw new AclDenied(from, "read", c);
|
|
371
|
+
for (const agg of Object.values(spec.aggregations)) {
|
|
372
|
+
if (agg.column && hiddenCols.has(agg.column as string)) throw new AclDenied(from, "read", agg.column as string);
|
|
373
|
+
}
|
|
374
|
+
|
|
347
375
|
if (scope.fields) {
|
|
348
376
|
const refs = new Set<string>(groupBy);
|
|
349
377
|
for (const agg of Object.values(spec.aggregations)) if (agg.column) refs.add(agg.column as string);
|
|
@@ -362,7 +390,29 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
362
390
|
// subqueries), then AND-merges the entity's own ACL row scope.
|
|
363
391
|
if (userWhere) this.assertReadableWhere(from, scope, userWhere);
|
|
364
392
|
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
|
|
365
|
-
|
|
393
|
+
const where = scope.where ? and(userExpr, scope.where) : userExpr;
|
|
394
|
+
// A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
|
|
395
|
+
// `sub` node over another table. Record those tables in `touched` so the live-query
|
|
396
|
+
// layer re-checks the subscription when the traversed table changes — else a write
|
|
397
|
+
// there never intersects the sub's read-set and the client stays stale.
|
|
398
|
+
this.addTouchedTables(where);
|
|
399
|
+
return where;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Walk a compiled predicate and add every relation-subquery target table to
|
|
403
|
+
* `touched` (the live-query read-set). Recurses into nested subqueries. */
|
|
404
|
+
private addTouchedTables(expr: SqlExpr | null | undefined): void {
|
|
405
|
+
if (!expr) return;
|
|
406
|
+
switch (expr.t) {
|
|
407
|
+
case "sub":
|
|
408
|
+
this.touched.add(expr.from);
|
|
409
|
+
this.addTouchedTables(expr.where);
|
|
410
|
+
break;
|
|
411
|
+
case "and":
|
|
412
|
+
case "or":
|
|
413
|
+
for (const p of expr.parts) this.addTouchedTables(p);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
366
416
|
}
|
|
367
417
|
|
|
368
418
|
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
@@ -401,6 +451,16 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
401
451
|
.map(([n]) => n);
|
|
402
452
|
}
|
|
403
453
|
|
|
454
|
+
/** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
|
|
455
|
+
* true/false on read so handlers see the `boolean` the InferRow type promises. */
|
|
456
|
+
private boolColsOf(table: string): string[] {
|
|
457
|
+
const fields = this.schema[table]?.fields;
|
|
458
|
+
if (!fields) return [];
|
|
459
|
+
return Object.entries(fields)
|
|
460
|
+
.filter(([, f]) => (f as FieldDef).type === "boolean")
|
|
461
|
+
.map(([n]) => n);
|
|
462
|
+
}
|
|
463
|
+
|
|
404
464
|
/** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
|
|
405
465
|
private hiddenColsOf(table: string): string[] {
|
|
406
466
|
const fields = this.schema[table]?.fields;
|
|
@@ -487,7 +547,8 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
487
547
|
|
|
488
548
|
private decodeRows(table: string, rows: Row[]): Row[] {
|
|
489
549
|
const cols = this.jsonColsOf(table);
|
|
490
|
-
|
|
550
|
+
const boolCols = this.boolColsOf(table);
|
|
551
|
+
if (cols.length === 0 && boolCols.length === 0) return rows;
|
|
491
552
|
for (const row of rows) {
|
|
492
553
|
for (const c of cols) {
|
|
493
554
|
const v = row[c];
|
|
@@ -499,6 +560,12 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
499
560
|
}
|
|
500
561
|
}
|
|
501
562
|
}
|
|
563
|
+
// INTEGER 0/1 -> boolean (leave NULL as null for a nullable bool column).
|
|
564
|
+
for (const c of boolCols) {
|
|
565
|
+
const v = row[c];
|
|
566
|
+
if (typeof v === "number") row[c] = v !== 0;
|
|
567
|
+
else if (typeof v === "bigint") row[c] = v !== 0n;
|
|
568
|
+
}
|
|
502
569
|
}
|
|
503
570
|
return rows;
|
|
504
571
|
}
|
|
@@ -556,7 +623,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
556
623
|
const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
|
|
557
624
|
if (!scope.allowed) throw new AclDenied(rel.target, "read");
|
|
558
625
|
|
|
559
|
-
|
|
626
|
+
// Strip hidden() columns from the relation load, like every other read path —
|
|
627
|
+
// projectRow alone keeps them under a full/allow()/SYSTEM scope (fields === null).
|
|
628
|
+
const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
|
|
560
629
|
// One IN query per relation (no N+1). Match column before projecting (which
|
|
561
630
|
// may drop the join column).
|
|
562
631
|
const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
|
|
@@ -574,14 +643,16 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
574
643
|
for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys)) byId.set(key, row);
|
|
575
644
|
for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
|
|
576
645
|
} else {
|
|
577
|
-
// hasMany: target[column] -> parent
|
|
578
|
-
|
|
646
|
+
// hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
|
|
647
|
+
// slug/username would otherwise join on an undefined `r.id` and get []).
|
|
648
|
+
const pk = this.pkOf(parentEntity);
|
|
649
|
+
const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
|
|
579
650
|
const grouped = new Map<unknown, Row[]>();
|
|
580
651
|
for (const { key, row } of await fetchBy(rel.column, ids)) {
|
|
581
652
|
const bucket = grouped.get(key) ?? grouped.set(key, []).get(key)!;
|
|
582
653
|
bucket.push(row);
|
|
583
654
|
}
|
|
584
|
-
for (const r of rows) r[relName] = grouped.get(r
|
|
655
|
+
for (const r of rows) r[relName] = grouped.get(r[pk]) ?? [];
|
|
585
656
|
}
|
|
586
657
|
}
|
|
587
658
|
|
package/src/runtime/ddl.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// these are applied.
|
|
4
4
|
|
|
5
5
|
import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
|
|
6
|
+
import { quoteIdent } from "./driver";
|
|
6
7
|
|
|
7
8
|
// SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
|
|
8
9
|
// stored as TEXT. Exported for the migrator, which compares declared column types
|
|
@@ -14,14 +15,27 @@ export const sqlType = (f: FieldDef): string =>
|
|
|
14
15
|
? "TEXT"
|
|
15
16
|
: f.type.toUpperCase();
|
|
16
17
|
|
|
17
|
-
/** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1.
|
|
18
|
-
|
|
18
|
+
/** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1.
|
|
19
|
+
* Exported for the migrator, which reconstructs a column's expected DEFAULT text to
|
|
20
|
+
* compare against the live `PRAGMA table_info.dflt_value`. */
|
|
21
|
+
export function defaultLiteral(v: DefaultValue): string {
|
|
19
22
|
if (v === null) return "NULL";
|
|
20
23
|
if (typeof v === "boolean") return v ? "1" : "0";
|
|
21
24
|
if (typeof v === "number") return String(v);
|
|
22
25
|
return `'${v.replace(/'/g, "''")}'`;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** The SQL text of a column's DEFAULT value (the part after `DEFAULT `), or null when
|
|
29
|
+
* the column declares no default. A raw-SQL `defaultExpr` is returned unquoted (e.g.
|
|
30
|
+
* `datetime('now')`); a literal `default` is rendered via {@link defaultLiteral}. Used
|
|
31
|
+
* by the migrator both to detect a default add/change on an existing column and to
|
|
32
|
+
* COALESCE-backfill a NOT NULL column during a rebuild. */
|
|
33
|
+
export function defaultSqlValue(f: FieldDef): string | null {
|
|
34
|
+
if (f.defaultExpr !== undefined) return f.defaultExpr;
|
|
35
|
+
if (f.default !== undefined) return defaultLiteral(f.default);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
|
|
26
40
|
* `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
|
|
27
41
|
* quote-escaped. UNIQUE/index are NOT inline — they're emitted as separate index
|
|
@@ -35,7 +49,7 @@ function defaultSql(f: FieldDef): string {
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
function columnSql(name: string, f: FieldDef): string {
|
|
38
|
-
let s = `${name} ${sqlType(f)}`;
|
|
52
|
+
let s = `${quoteIdent(name)} ${sqlType(f)}`;
|
|
39
53
|
if (f.primaryKey) s += " PRIMARY KEY";
|
|
40
54
|
if (f.autoIncrement) s += " AUTOINCREMENT";
|
|
41
55
|
if (f.notNull && !f.primaryKey) s += " NOT NULL";
|
|
@@ -45,14 +59,14 @@ function columnSql(name: string, f: FieldDef): string {
|
|
|
45
59
|
|
|
46
60
|
export function createTableSql(table: string, def: { fields: EntityFields }): string {
|
|
47
61
|
const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
|
|
48
|
-
return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
|
|
62
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
/** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
|
|
52
66
|
* NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
|
|
53
67
|
* a populated table); a DEFAULT alone backfills existing rows. */
|
|
54
68
|
export function addColumnSql(name: string, f: FieldDef): string {
|
|
55
|
-
let s = `${name} ${sqlType(f)}`;
|
|
69
|
+
let s = `${quoteIdent(name)} ${sqlType(f)}`;
|
|
56
70
|
if (f.notNull && f.default !== undefined) s += " NOT NULL";
|
|
57
71
|
s += defaultSql(f);
|
|
58
72
|
return s;
|
|
@@ -64,13 +78,17 @@ export function indexName(table: string, col: string): string {
|
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
|
|
67
|
-
* via IF NOT EXISTS). Unique wins if a column declares both.
|
|
68
|
-
|
|
81
|
+
* via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
|
|
82
|
+
* columns — the migrator uses it to avoid emitting a UNIQUE index that would throw
|
|
83
|
+
* (duplicate values present on a column that just gained `unique()`); that delta is
|
|
84
|
+
* reported as skipped instead. */
|
|
85
|
+
export function indexStatements(table: string, def: { fields: EntityFields }, skipCols?: ReadonlySet<string>): string[] {
|
|
69
86
|
const out: string[] = [];
|
|
70
87
|
for (const [col, f] of Object.entries(def.fields)) {
|
|
71
88
|
if (!f.unique && !f.index) continue;
|
|
89
|
+
if (skipCols?.has(col)) continue;
|
|
72
90
|
const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
|
|
73
|
-
out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
|
|
91
|
+
out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
|
|
74
92
|
}
|
|
75
93
|
return out;
|
|
76
94
|
}
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { warmup, type AclContext } from "./acl";
|
|
|
13
13
|
import { BadRequest, Forbidden } from "./errors";
|
|
14
14
|
import { enqueueTask, type TaskMap } from "./outbox";
|
|
15
15
|
import { createMail } from "./mail";
|
|
16
|
+
import { createQueue } from "./queue";
|
|
16
17
|
import type { Driver } from "./driver";
|
|
17
18
|
import type { Kv } from "./kv";
|
|
18
19
|
import type { Files } from "../sdk/files";
|
|
@@ -91,6 +92,7 @@ export async function dispatch(
|
|
|
91
92
|
identity: acl.identity,
|
|
92
93
|
tasks: tasksFacade(driver, () => enqueued++),
|
|
93
94
|
mail: createMail(env, kv),
|
|
95
|
+
queue: createQueue(env),
|
|
94
96
|
};
|
|
95
97
|
|
|
96
98
|
const result =
|
package/src/runtime/driver.ts
CHANGED
|
@@ -33,10 +33,20 @@ function checkIdent(name: string): string {
|
|
|
33
33
|
return name;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
36
|
+
/** Render an identifier as a standard double-quoted name (`"order"`), guarding its
|
|
37
|
+
* shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
|
|
38
|
+
* identifiers, so a column/table named after a reserved word (`order`, `group`, …)
|
|
39
|
+
* is safe and case is preserved. The single source of truth for both dialects and
|
|
40
|
+
* the DDL generator — keep every emitted identifier going through this. */
|
|
41
|
+
export function quoteIdent(name: string): string {
|
|
42
|
+
return `"${checkIdent(name)}"`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
|
|
46
|
+
* words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
|
|
47
|
+
* RETURNING supported. */
|
|
38
48
|
export const sqliteDialect: Dialect = {
|
|
39
|
-
id:
|
|
49
|
+
id: quoteIdent,
|
|
40
50
|
placeholder: () => "?",
|
|
41
51
|
returning: true,
|
|
42
52
|
encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
|
|
@@ -46,7 +56,7 @@ export const sqliteDialect: Dialect = {
|
|
|
46
56
|
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
47
57
|
* RETURNING supported. */
|
|
48
58
|
export const postgresDialect: Dialect = {
|
|
49
|
-
id:
|
|
59
|
+
id: quoteIdent,
|
|
50
60
|
placeholder: (n) => `$${n}`,
|
|
51
61
|
returning: true,
|
|
52
62
|
encode: (v) => v, // the pg driver handles type encoding
|
|
@@ -76,19 +86,52 @@ export class DoSqliteDriver implements Driver {
|
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
/**
|
|
80
|
-
* `
|
|
81
|
-
*
|
|
89
|
+
/** How a D1Driver's session is anchored (passed to `db.withSession`):
|
|
90
|
+
* - `"first-primary"` — first query hits the primary (current data), the rest
|
|
91
|
+
* read replicas consistent with the session bookmark. Use
|
|
92
|
+
* for a MUTATION (reads must see current data; writes go
|
|
93
|
+
* to primary anyway).
|
|
94
|
+
* - `"first-unconstrained"` — first query may hit the nearest replica. Use for a QUERY.
|
|
95
|
+
* - a bookmark string — anchor at a prior write's bookmark for read-your-writes
|
|
96
|
+
* (the client carries it forward via a header).
|
|
97
|
+
* A bookmark always wins over a constraint when one is supplied. */
|
|
98
|
+
export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
|
|
99
|
+
|
|
100
|
+
/** D1 — SQLite over RPC. Async by nature.
|
|
101
|
+
*
|
|
102
|
+
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
103
|
+
* runs all `exec` through it. Writes in a session always land on the primary; the
|
|
104
|
+
* `start` only chooses where the FIRST read may begin. The session maintains a
|
|
105
|
+
* bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
|
|
106
|
+
* writes — read-your-writes when the bookmark is threaded across requests.
|
|
107
|
+
*
|
|
108
|
+
* ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
|
|
109
|
+
* read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
|
|
110
|
+
* trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
|
|
111
|
+
* statement auto-commits on its own, and a multi-statement mutation does NOT roll back
|
|
112
|
+
* on throw the way it does on a DO. Single-statement mutations are atomic; anything
|
|
113
|
+
* multi-statement is not. Use the DO store when you need atomic mutations. */
|
|
82
114
|
export class D1Driver implements Driver {
|
|
83
115
|
readonly dialect = sqliteDialect;
|
|
84
|
-
|
|
116
|
+
private readonly session: D1DatabaseSession;
|
|
117
|
+
constructor(db: D1Database, opts?: { start?: D1SessionStart }) {
|
|
118
|
+
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
119
|
+
}
|
|
85
120
|
|
|
86
121
|
async exec(sql: string, params: unknown[]): Promise<Row[]> {
|
|
87
|
-
const stmt = params.length ? this.
|
|
122
|
+
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
88
123
|
const { results } = await stmt.all<Row>();
|
|
89
124
|
return results ?? [];
|
|
90
125
|
}
|
|
91
126
|
|
|
127
|
+
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
128
|
+
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
129
|
+
* fresh session at it and read its own writes. */
|
|
130
|
+
getBookmark(): string | null {
|
|
131
|
+
return this.session.getBookmark();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// D1 has no interactive/atomic transactions — see the class doc. Run `fn` as-is.
|
|
92
135
|
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
93
136
|
return fn();
|
|
94
137
|
}
|