@pramen/server 0.0.14 → 0.0.16
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 +119 -55
- 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 +23 -4
- package/dist/runtime/migrate.js +213 -20
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +9 -2
- package/dist/runtime/queue-consumer.js +23 -6
- 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/worker.d.ts +15 -0
- package/dist/worker.js +44 -11
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +116 -52
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +21 -4
- package/src/runtime/migrate.ts +237 -20
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +24 -5
- 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/worker.ts +62 -11
package/src/durable-object.ts
CHANGED
|
@@ -35,14 +35,17 @@ import type { HandlerContext } from "./sdk/handlers";
|
|
|
35
35
|
import type { PramenApp } from "./pramen";
|
|
36
36
|
import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** Durable per-socket state — kept SMALL and stable, since it rides the WebSocket
|
|
39
|
+
* attachment which workerd caps at ~2 KB. Only auth/routing identity lives here so it
|
|
40
|
+
* survives hibernation; the (potentially large) subscription list does NOT — see
|
|
41
|
+
* `subsBySocket`. */
|
|
42
|
+
interface SocketAttachment {
|
|
39
43
|
identity: Identity | null;
|
|
40
44
|
/** Tenant fixed at connect time (survives hibernation via the attachment). */
|
|
41
45
|
tenant: string;
|
|
42
46
|
/** Partition fixed at connect time (read from x-pramen-partition at upgrade);
|
|
43
47
|
* survives hibernation via the attachment, like `tenant`. */
|
|
44
48
|
partition: string;
|
|
45
|
-
subs: Subscription[];
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export interface DoEnv {
|
|
@@ -84,6 +87,14 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
84
87
|
* task context — a DO can't introspect its own idFromName. */
|
|
85
88
|
private identityPersisted = false;
|
|
86
89
|
private identityLoaded = false;
|
|
90
|
+
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
91
|
+
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
92
|
+
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
93
|
+
* tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
|
|
94
|
+
* entry and is treated as having no active subscriptions — acceptable because the
|
|
95
|
+
* client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
|
|
96
|
+
* cleaned up in webSocketClose. */
|
|
97
|
+
private readonly subsBySocket = new Map<WebSocket, Subscription[]>();
|
|
87
98
|
|
|
88
99
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
|
|
89
100
|
super(ctx, env);
|
|
@@ -161,7 +172,8 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
161
172
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
162
173
|
const { 0: client, 1: server } = new WebSocketPair();
|
|
163
174
|
this.ctx.acceptWebSocket(server); // hibernatable
|
|
164
|
-
this.
|
|
175
|
+
this.setAttachment(server, { identity, tenant: this.tenant, partition: this.partition });
|
|
176
|
+
this.subsBySocket.set(server, []);
|
|
165
177
|
return new Response(null, { status: 101, webSocket: client });
|
|
166
178
|
}
|
|
167
179
|
|
|
@@ -183,8 +195,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
183
195
|
name,
|
|
184
196
|
input,
|
|
185
197
|
);
|
|
186
|
-
|
|
198
|
+
// Arm the drain BEFORE broadcasting so enqueued tasks are always scheduled even
|
|
199
|
+
// if broadcast has trouble (broadcast is best-effort and never throws — a failed
|
|
200
|
+
// push must not 500 a COMMITTED write nor skip the alarm).
|
|
187
201
|
if (enqueued > 0) await this.armDrain();
|
|
202
|
+
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
188
203
|
return Response.json({ ok: true, result });
|
|
189
204
|
} catch (err) {
|
|
190
205
|
const { status, body } = toResponse(err);
|
|
@@ -199,15 +214,17 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
199
214
|
}
|
|
200
215
|
|
|
201
216
|
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
202
|
-
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
203
|
-
|
|
217
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
218
|
+
* Returns the `db` alongside the ctx so the drainer can broadcast the tables the task
|
|
219
|
+
* handlers touched (live queries would otherwise go stale after deferred/trigger work). */
|
|
220
|
+
private taskCtx(): { ctx: HandlerContext; db: Db } {
|
|
204
221
|
const identity: Identity = { roles: ["admin"] };
|
|
205
222
|
const db = new Db(
|
|
206
223
|
this.driver,
|
|
207
224
|
{ acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true },
|
|
208
225
|
this.app.schema,
|
|
209
226
|
);
|
|
210
|
-
|
|
227
|
+
const ctx: HandlerContext = {
|
|
211
228
|
db,
|
|
212
229
|
kv: this.kv,
|
|
213
230
|
files: this.filesFor(this.tenant),
|
|
@@ -217,6 +234,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
217
234
|
mail: createMail(this.envBag, this.kv),
|
|
218
235
|
queue: createQueue(this.envBag),
|
|
219
236
|
};
|
|
237
|
+
return { ctx, db };
|
|
220
238
|
}
|
|
221
239
|
|
|
222
240
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
@@ -237,23 +255,33 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
240
|
-
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling
|
|
241
|
-
|
|
258
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling, plus
|
|
259
|
+
* the union of tables the drained task handlers touched (for a post-commit broadcast). */
|
|
260
|
+
private async drainTasks(): Promise<{ result: Awaited<ReturnType<typeof drainOutbox>>; touched: string[] }> {
|
|
242
261
|
await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
|
|
243
|
-
|
|
262
|
+
const { ctx, db } = this.taskCtx();
|
|
263
|
+
const result = await drainOutbox(this.driver, bindTasks(this.app.tasks, ctx), Date.now());
|
|
264
|
+
return { result, touched: [...db.touched] };
|
|
244
265
|
}
|
|
245
266
|
|
|
246
267
|
override async alarm(): Promise<void> {
|
|
247
268
|
await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
|
|
248
|
-
|
|
269
|
+
// A post-deploy cold alarm may run against the old schema — reconcile it first, or a
|
|
270
|
+
// task handler writing a new column dead-letters. loadIdentity() restored the partition.
|
|
271
|
+
await this.ensureMigrated();
|
|
272
|
+
const { result, touched } = await this.drainTasks();
|
|
273
|
+
// Deferred/triggered writes are invisible to live queries unless we broadcast the
|
|
274
|
+
// tables the task handlers touched (post-commit — the drain has already committed).
|
|
275
|
+
if (touched.length > 0) await this.broadcast(touched);
|
|
249
276
|
// Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
|
|
250
277
|
// the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
|
|
251
|
-
if (nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
|
|
278
|
+
if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
252
279
|
}
|
|
253
280
|
|
|
254
281
|
private async handleDrain(): Promise<Response> {
|
|
255
282
|
await this.loadIdentity();
|
|
256
|
-
const result = await this.drainTasks();
|
|
283
|
+
const { result, touched } = await this.drainTasks();
|
|
284
|
+
if (touched.length > 0) await this.broadcast(touched);
|
|
257
285
|
// Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
|
|
258
286
|
if (result.nextRunAt != null) await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
259
287
|
return Response.json({ ok: true, result });
|
|
@@ -282,7 +310,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
282
310
|
// socket's (tenant, partition) — fixed at connect time, survives via the attachment
|
|
283
311
|
// — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
|
|
284
312
|
// (the `migrated` flag), so a no-op after the first call.
|
|
285
|
-
const { tenant, partition } = this.
|
|
313
|
+
const { tenant, partition } = this.getAttachment(ws);
|
|
286
314
|
this.tenant = tenant;
|
|
287
315
|
this.partition = partition;
|
|
288
316
|
await this.ensureMigrated();
|
|
@@ -290,11 +318,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
290
318
|
switch (msg.type) {
|
|
291
319
|
case "subscribe":
|
|
292
320
|
return this.onSubscribe(ws, msg.id, msg.name, msg.input);
|
|
293
|
-
case "unsubscribe":
|
|
294
|
-
|
|
295
|
-
this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
|
|
321
|
+
case "unsubscribe":
|
|
322
|
+
this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
|
|
296
323
|
return;
|
|
297
|
-
}
|
|
298
324
|
case "call":
|
|
299
325
|
return this.onCall(ws, msg.id, msg.name, msg.input);
|
|
300
326
|
default:
|
|
@@ -303,6 +329,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
override async webSocketClose(ws: WebSocket): Promise<void> {
|
|
332
|
+
this.subsBySocket.delete(ws); // release the in-memory subscription list for this socket
|
|
306
333
|
ws.close();
|
|
307
334
|
}
|
|
308
335
|
|
|
@@ -318,19 +345,20 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
318
345
|
// --- live-query internals ---
|
|
319
346
|
|
|
320
347
|
private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
|
|
321
|
-
const
|
|
348
|
+
const att = this.getAttachment(ws);
|
|
349
|
+
const subs = this.getSubs(ws);
|
|
322
350
|
try {
|
|
323
|
-
const replacing =
|
|
324
|
-
if (!replacing &&
|
|
351
|
+
const replacing = subs.some((s) => s.id === id);
|
|
352
|
+
if (!replacing && subs.length >= MAX_SUBSCRIPTIONS) {
|
|
325
353
|
return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
|
|
326
354
|
}
|
|
327
|
-
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(
|
|
355
|
+
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
328
356
|
if (kind !== "query") {
|
|
329
357
|
return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
|
|
330
358
|
}
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
this.
|
|
359
|
+
const next = subs.filter((s) => s.id !== id);
|
|
360
|
+
next.push({ id, name, input, tables: touched, digest: digest(result) });
|
|
361
|
+
this.setSubs(ws, next);
|
|
334
362
|
this.send(ws, { type: "data", id, result });
|
|
335
363
|
} catch (err) {
|
|
336
364
|
this.send(ws, toWsError(id, err));
|
|
@@ -338,38 +366,57 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
338
366
|
}
|
|
339
367
|
|
|
340
368
|
private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
|
|
341
|
-
const
|
|
369
|
+
const att = this.getAttachment(ws);
|
|
370
|
+
let outcome: Awaited<ReturnType<typeof dispatch>>;
|
|
342
371
|
try {
|
|
343
|
-
|
|
344
|
-
this.send(ws, { type: "result", id, result });
|
|
345
|
-
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
346
|
-
if (enqueued > 0) await this.armDrain();
|
|
372
|
+
outcome = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
347
373
|
} catch (err) {
|
|
348
|
-
this.send(ws, toWsError(id, err));
|
|
374
|
+
return this.send(ws, toWsError(id, err));
|
|
349
375
|
}
|
|
376
|
+
// The mutation is committed — send its result FIRST, then run post-commit
|
|
377
|
+
// side-effects that must never turn a committed write into a spurious error frame:
|
|
378
|
+
// arm the drain (independent of broadcast), then broadcast (best-effort, never throws).
|
|
379
|
+
const { result, kind, touched, enqueued } = outcome;
|
|
380
|
+
this.send(ws, { type: "result", id, result });
|
|
381
|
+
if (enqueued > 0) await this.armDrain();
|
|
382
|
+
if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
|
|
350
383
|
}
|
|
351
384
|
|
|
352
385
|
// Re-run every subscription whose read-set intersects the written tables, each
|
|
353
|
-
// under its own socket's identity, and push only when its result changed.
|
|
386
|
+
// under its own socket's identity, and push only when its result changed. Best-effort:
|
|
387
|
+
// a failure for one subscription or socket is logged and skipped — it must NEVER throw,
|
|
388
|
+
// because it runs after a mutation has committed (a throw here would 500 that write).
|
|
354
389
|
private async broadcast(touched: string[]): Promise<void> {
|
|
355
390
|
const written = new Set(touched);
|
|
356
391
|
for (const ws of this.ctx.getWebSockets()) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
392
|
+
try {
|
|
393
|
+
const att = this.getAttachment(ws);
|
|
394
|
+
const subs = this.getSubs(ws);
|
|
395
|
+
let dirty = false;
|
|
396
|
+
for (const sub of subs) {
|
|
397
|
+
if (!sub.tables.some((t) => written.has(t))) continue;
|
|
398
|
+
try {
|
|
399
|
+
const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), sub.name, sub.input);
|
|
400
|
+
const next = digest(result);
|
|
401
|
+
if (next === sub.digest) continue; // result unchanged for this subscription
|
|
402
|
+
sub.digest = next;
|
|
403
|
+
dirty = true;
|
|
404
|
+
this.send(ws, { type: "data", id: sub.id, result });
|
|
405
|
+
} catch (err) {
|
|
406
|
+
// One subscription failing (re-dispatch error, or a send to a dead socket)
|
|
407
|
+
// must not abort the other subs — surface it to that sub, swallow otherwise.
|
|
408
|
+
try {
|
|
409
|
+
this.send(ws, toWsError(sub.id, err));
|
|
410
|
+
} catch {
|
|
411
|
+
/* socket already gone */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
370
414
|
}
|
|
415
|
+
if (dirty) this.setSubs(ws, subs);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
// A bad socket must not stop the loop over the others.
|
|
418
|
+
console.error("pramen: broadcast to a socket failed", err);
|
|
371
419
|
}
|
|
372
|
-
if (dirty) this.setState(ws, state);
|
|
373
420
|
}
|
|
374
421
|
}
|
|
375
422
|
|
|
@@ -468,7 +515,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
468
515
|
result = await db.count({ from: table, where: b.where });
|
|
469
516
|
break;
|
|
470
517
|
case "get":
|
|
471
|
-
|
|
518
|
+
// Resolve the PK from the schema — a custom-PK table (e.g. auth_users keyed on
|
|
519
|
+
// `username`) has no `id` column, so a hardcoded `{ id }` would 500.
|
|
520
|
+
result = (await db.find({ from: table, where: { [db.pkOf(table)]: b.id }, limit: 1 }))[0] ?? null;
|
|
472
521
|
break;
|
|
473
522
|
case "create":
|
|
474
523
|
result = await this.driver.transaction(() => db.insert(table, b.values));
|
|
@@ -485,6 +534,9 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
485
534
|
default:
|
|
486
535
|
return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
|
|
487
536
|
}
|
|
537
|
+
// Admin-data writes fire triggers too — arm the drain if any task was enqueued
|
|
538
|
+
// (independent of broadcast), then broadcast the touched tables to live queries.
|
|
539
|
+
if (mutated && db.taskEnqueues > 0) await this.armDrain();
|
|
488
540
|
if (mutated && db.touched.size > 0) await this.broadcast([...db.touched]);
|
|
489
541
|
return Response.json({ ok: true, result });
|
|
490
542
|
} catch (err) {
|
|
@@ -526,19 +578,31 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
526
578
|
}
|
|
527
579
|
}
|
|
528
580
|
|
|
529
|
-
|
|
581
|
+
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
582
|
+
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
583
|
+
private getAttachment(ws: WebSocket): SocketAttachment {
|
|
530
584
|
return (
|
|
531
|
-
(ws.deserializeAttachment() as
|
|
585
|
+
(ws.deserializeAttachment() as SocketAttachment | null) ?? {
|
|
532
586
|
identity: null,
|
|
533
587
|
tenant: this.tenant,
|
|
534
588
|
partition: this.partition,
|
|
535
|
-
subs: [],
|
|
536
589
|
}
|
|
537
590
|
);
|
|
538
591
|
}
|
|
539
592
|
|
|
540
|
-
private
|
|
541
|
-
ws.serializeAttachment(
|
|
593
|
+
private setAttachment(ws: WebSocket, att: SocketAttachment): void {
|
|
594
|
+
ws.serializeAttachment(att);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** This socket's live subscriptions from the in-memory map (see `subsBySocket`). A
|
|
598
|
+
* hibernated/woken socket has no entry → no active subscriptions until the client
|
|
599
|
+
* replays them on reconnect. */
|
|
600
|
+
private getSubs(ws: WebSocket): Subscription[] {
|
|
601
|
+
return this.subsBySocket.get(ws) ?? [];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private setSubs(ws: WebSocket, subs: Subscription[]): void {
|
|
605
|
+
this.subsBySocket.set(ws, subs);
|
|
542
606
|
}
|
|
543
607
|
|
|
544
608
|
private send(ws: WebSocket, msg: ServerMsg): void {
|
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
|
@@ -15,14 +15,27 @@ export const sqlType = (f: FieldDef): string =>
|
|
|
15
15
|
? "TEXT"
|
|
16
16
|
: f.type.toUpperCase();
|
|
17
17
|
|
|
18
|
-
/** Render a DEFAULT literal. Strings are single-quote-escaped; booleans → 0/1.
|
|
19
|
-
|
|
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 {
|
|
20
22
|
if (v === null) return "NULL";
|
|
21
23
|
if (typeof v === "boolean") return v ? "1" : "0";
|
|
22
24
|
if (typeof v === "number") return String(v);
|
|
23
25
|
return `'${v.replace(/'/g, "''")}'`;
|
|
24
26
|
}
|
|
25
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
|
+
|
|
26
39
|
/** The ` DEFAULT x` fragment for a column, or "" when it has no default. A raw-SQL
|
|
27
40
|
* `defaultExpr` (e.g. `datetime('now')`) is emitted UNQUOTED; a literal `default` is
|
|
28
41
|
* quote-escaped. UNIQUE/index are NOT inline — they're emitted as separate index
|
|
@@ -65,11 +78,15 @@ export function indexName(table: string, col: string): string {
|
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
|
|
68
|
-
* via IF NOT EXISTS). Unique wins if a column declares both.
|
|
69
|
-
|
|
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[] {
|
|
70
86
|
const out: string[] = [];
|
|
71
87
|
for (const [col, f] of Object.entries(def.fields)) {
|
|
72
88
|
if (!f.unique && !f.index) continue;
|
|
89
|
+
if (skipCols?.has(col)) continue;
|
|
73
90
|
const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
|
|
74
91
|
out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
|
|
75
92
|
}
|