@pramen/server 0.0.12 → 0.0.14
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/durable-object.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/ddl.js +5 -4
- package/dist/runtime/dispatch.d.ts +1 -1
- package/dist/runtime/dispatch.js +9 -1
- 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 +10 -14
- package/dist/runtime/queue-consumer.d.ts +64 -0
- package/dist/runtime/queue-consumer.js +46 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/sdk/app.js +2 -2
- package/dist/sdk/handlers.d.ts +22 -0
- package/dist/sdk/handlers.js +14 -2
- package/dist/worker.d.ts +21 -0
- package/dist/worker.js +90 -13
- package/package.json +1 -1
- package/src/durable-object.ts +2 -0
- package/src/index.ts +8 -2
- package/src/pramen.ts +7 -1
- package/src/runtime/ddl.ts +5 -4
- package/src/runtime/dispatch.ts +10 -2
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +10 -15
- package/src/runtime/queue-consumer.ts +97 -0
- package/src/runtime/queue.ts +155 -0
- package/src/sdk/app.ts +2 -2
- package/src/sdk/handlers.ts +33 -2
- package/src/worker.ts +105 -13
package/dist/durable-object.js
CHANGED
|
@@ -18,6 +18,7 @@ import { DurableObject } from "cloudflare:workers";
|
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
19
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
20
20
|
import { createMail } from "./runtime/mail";
|
|
21
|
+
import { createQueue } from "./runtime/queue";
|
|
21
22
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
22
23
|
import { Db } from "./runtime/db";
|
|
23
24
|
import { digest } from "./runtime/digest";
|
|
@@ -163,6 +164,7 @@ export class PramenDOBase extends DurableObject {
|
|
|
163
164
|
identity,
|
|
164
165
|
tasks: tasksFacade(this.driver),
|
|
165
166
|
mail: createMail(this.envBag, this.kv),
|
|
167
|
+
queue: createQueue(this.envBag),
|
|
166
168
|
};
|
|
167
169
|
}
|
|
168
170
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
|
3
3
|
export { isValidUuid } from "./sdk/uuid";
|
|
4
4
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
|
|
5
5
|
export { createApp } from "./sdk/app";
|
|
6
|
-
export { query, mutation } from "./sdk/handlers";
|
|
7
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
6
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
7
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
8
8
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
9
9
|
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
10
10
|
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
|
@@ -13,6 +13,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
|
|
|
13
13
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
14
14
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
15
15
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
16
|
+
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
17
|
+
export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
|
|
18
|
+
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
19
|
+
export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
|
|
16
20
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
17
21
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
18
22
|
export type { Driver, Dialect, Row } from "./runtime/driver";
|
package/dist/index.js
CHANGED
|
@@ -11,12 +11,15 @@ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, de
|
|
|
11
11
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
12
|
// --- app + handlers ---
|
|
13
13
|
export { createApp } from "./sdk/app";
|
|
14
|
-
export { query, mutation } from "./sdk/handlers";
|
|
14
|
+
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
15
15
|
// --- ACL ---
|
|
16
16
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
17
17
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
18
18
|
// --- mail (ctx.mail) ---
|
|
19
19
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
20
|
+
// --- queue (ctx.queue — Cloudflare Queues) ---
|
|
21
|
+
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
22
|
+
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
20
23
|
// --- errors ---
|
|
21
24
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
22
25
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
package/dist/pramen.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Env } from "./worker";
|
|
|
2
2
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
3
|
import { type SchemaDef } from "./sdk/schema";
|
|
4
4
|
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
5
|
+
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
5
6
|
import type { Role } from "./sdk/acl";
|
|
6
7
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
7
8
|
* tenant's DO without the handler importing any deploy-side code (so app.ts stays
|
|
@@ -36,6 +37,10 @@ export interface PramenApp {
|
|
|
36
37
|
/** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
|
|
37
38
|
* mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
|
|
38
39
|
tasks?: AppTaskMap;
|
|
40
|
+
/** Cloudflare Queues consumers keyed by queue name — process messages produced via
|
|
41
|
+
* `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
|
|
42
|
+
* Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
|
|
43
|
+
queues?: AppQueueMap;
|
|
39
44
|
}
|
|
40
45
|
export type { Env, DoEnv };
|
|
41
46
|
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
@@ -44,5 +49,6 @@ export type { Env, DoEnv };
|
|
|
44
49
|
export declare function createPramen(app: PramenApp): {
|
|
45
50
|
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
46
51
|
scheduled: (event: unknown, env: Env) => Promise<void>;
|
|
52
|
+
queue: (batch: QueueBatch, env: Env) => Promise<void>;
|
|
47
53
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
48
54
|
};
|
package/dist/pramen.js
CHANGED
|
@@ -20,5 +20,5 @@ import { validateTriggerTasks } from "./sdk/schema";
|
|
|
20
20
|
export function createPramen(app) {
|
|
21
21
|
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
22
22
|
const worker = makeWorker(app);
|
|
23
|
-
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
23
|
+
return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
|
|
24
24
|
}
|
package/dist/runtime/ddl.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// DDL generation — CREATE TABLE for a new entity and the additive ALTER fragment
|
|
2
2
|
// for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
|
|
3
3
|
// these are applied.
|
|
4
|
+
import { quoteIdent } from "./driver";
|
|
4
5
|
// SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
|
|
5
6
|
// stored as TEXT. Exported for the migrator, which compares declared column types
|
|
6
7
|
// (and CASTs on a type change).
|
|
@@ -32,7 +33,7 @@ function defaultSql(f) {
|
|
|
32
33
|
return f.default !== undefined ? ` DEFAULT ${defaultLiteral(f.default)}` : "";
|
|
33
34
|
}
|
|
34
35
|
function columnSql(name, f) {
|
|
35
|
-
let s = `${name} ${sqlType(f)}`;
|
|
36
|
+
let s = `${quoteIdent(name)} ${sqlType(f)}`;
|
|
36
37
|
if (f.primaryKey)
|
|
37
38
|
s += " PRIMARY KEY";
|
|
38
39
|
if (f.autoIncrement)
|
|
@@ -44,13 +45,13 @@ function columnSql(name, f) {
|
|
|
44
45
|
}
|
|
45
46
|
export function createTableSql(table, def) {
|
|
46
47
|
const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
|
|
47
|
-
return `CREATE TABLE IF NOT EXISTS ${table} (${cols.join(", ")})`;
|
|
48
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${cols.join(", ")})`;
|
|
48
49
|
}
|
|
49
50
|
/** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
|
|
50
51
|
* NOT NULL is only emitted alongside a DEFAULT (SQLite can't add a bare NOT NULL to
|
|
51
52
|
* a populated table); a DEFAULT alone backfills existing rows. */
|
|
52
53
|
export function addColumnSql(name, f) {
|
|
53
|
-
let s = `${name} ${sqlType(f)}`;
|
|
54
|
+
let s = `${quoteIdent(name)} ${sqlType(f)}`;
|
|
54
55
|
if (f.notNull && f.default !== undefined)
|
|
55
56
|
s += " NOT NULL";
|
|
56
57
|
s += defaultSql(f);
|
|
@@ -68,7 +69,7 @@ export function indexStatements(table, def) {
|
|
|
68
69
|
if (!f.unique && !f.index)
|
|
69
70
|
continue;
|
|
70
71
|
const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
|
|
71
|
-
out.push(`CREATE ${kind} IF NOT EXISTS ${indexName(table, col)} ON ${table} (${col})`);
|
|
72
|
+
out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
|
|
72
73
|
}
|
|
73
74
|
return out;
|
|
74
75
|
}
|
|
@@ -4,7 +4,7 @@ import type { Driver } from "./driver";
|
|
|
4
4
|
import type { Kv } from "./kv";
|
|
5
5
|
import type { Files } from "../sdk/files";
|
|
6
6
|
import type { SchemaDef } from "../sdk/schema";
|
|
7
|
-
import type
|
|
7
|
+
import { type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
8
8
|
export interface DispatchResult {
|
|
9
9
|
readonly result: unknown;
|
|
10
10
|
readonly kind: HandlerKind;
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// layer can match a mutation's writes against each subscription's reads.
|
|
10
10
|
import { Db } from "./db";
|
|
11
11
|
import { warmup } from "./acl";
|
|
12
|
-
import { BadRequest } from "./errors";
|
|
12
|
+
import { BadRequest, Forbidden } from "./errors";
|
|
13
13
|
import { enqueueTask } from "./outbox";
|
|
14
14
|
import { createMail } from "./mail";
|
|
15
|
+
import { createQueue } from "./queue";
|
|
16
|
+
import { authorizeHandler } from "../sdk/handlers";
|
|
15
17
|
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
16
18
|
* so it can wake the drainer. */
|
|
17
19
|
export function tasksFacade(driver, onEnqueue) {
|
|
@@ -33,6 +35,11 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
33
35
|
const handler = handlers[name];
|
|
34
36
|
if (!handler)
|
|
35
37
|
throw new BadRequest(`unknown handler: ${name}`);
|
|
38
|
+
// Per-handler authorization, enforced before any work (input parse / handler body) —
|
|
39
|
+
// gates handlers that bypass the row-ACL by touching ctx.kv/ctx.env/ctx.mail directly.
|
|
40
|
+
if (handler.auth && !authorizeHandler(handler.auth, acl.identity)) {
|
|
41
|
+
throw new Forbidden(`not authorized to call '${name}'`);
|
|
42
|
+
}
|
|
36
43
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
37
44
|
let parsed = input;
|
|
38
45
|
if (handler.input) {
|
|
@@ -57,6 +64,7 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
57
64
|
identity: acl.identity,
|
|
58
65
|
tasks: tasksFacade(driver, () => enqueued++),
|
|
59
66
|
mail: createMail(env, kv),
|
|
67
|
+
queue: createQueue(env),
|
|
60
68
|
};
|
|
61
69
|
const result = handler.kind === "query"
|
|
62
70
|
? await handler.run(ctx, parsed)
|
package/dist/runtime/driver.d.ts
CHANGED
|
@@ -9,8 +9,15 @@ export interface Dialect {
|
|
|
9
9
|
/** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
|
|
10
10
|
encode(v: unknown): unknown;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
12
|
+
/** Render an identifier as a standard double-quoted name (`"order"`), guarding its
|
|
13
|
+
* shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
|
|
14
|
+
* identifiers, so a column/table named after a reserved word (`order`, `group`, …)
|
|
15
|
+
* is safe and case is preserved. The single source of truth for both dialects and
|
|
16
|
+
* the DDL generator — keep every emitted identifier going through this. */
|
|
17
|
+
export declare function quoteIdent(name: string): string;
|
|
18
|
+
/** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
|
|
19
|
+
* words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
|
|
20
|
+
* RETURNING supported. */
|
|
14
21
|
export declare const sqliteDialect: Dialect;
|
|
15
22
|
/** Postgres (e.g. over Hyperdrive). Double-quoted identifiers preserve case (so
|
|
16
23
|
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
@@ -33,13 +40,40 @@ export declare class DoSqliteDriver implements Driver {
|
|
|
33
40
|
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
34
41
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
35
42
|
}
|
|
36
|
-
/**
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
43
|
+
/** How a D1Driver's session is anchored (passed to `db.withSession`):
|
|
44
|
+
* - `"first-primary"` — first query hits the primary (current data), the rest
|
|
45
|
+
* read replicas consistent with the session bookmark. Use
|
|
46
|
+
* for a MUTATION (reads must see current data; writes go
|
|
47
|
+
* to primary anyway).
|
|
48
|
+
* - `"first-unconstrained"` — first query may hit the nearest replica. Use for a QUERY.
|
|
49
|
+
* - a bookmark string — anchor at a prior write's bookmark for read-your-writes
|
|
50
|
+
* (the client carries it forward via a header).
|
|
51
|
+
* A bookmark always wins over a constraint when one is supplied. */
|
|
52
|
+
export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
|
|
53
|
+
/** D1 — SQLite over RPC. Async by nature.
|
|
54
|
+
*
|
|
55
|
+
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
56
|
+
* runs all `exec` through it. Writes in a session always land on the primary; the
|
|
57
|
+
* `start` only chooses where the FIRST read may begin. The session maintains a
|
|
58
|
+
* bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
|
|
59
|
+
* writes — read-your-writes when the bookmark is threaded across requests.
|
|
60
|
+
*
|
|
61
|
+
* ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
|
|
62
|
+
* read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
|
|
63
|
+
* trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
|
|
64
|
+
* statement auto-commits on its own, and a multi-statement mutation does NOT roll back
|
|
65
|
+
* on throw the way it does on a DO. Single-statement mutations are atomic; anything
|
|
66
|
+
* multi-statement is not. Use the DO store when you need atomic mutations. */
|
|
39
67
|
export declare class D1Driver implements Driver {
|
|
40
|
-
private readonly db;
|
|
41
68
|
readonly dialect: Dialect;
|
|
42
|
-
|
|
69
|
+
private readonly session;
|
|
70
|
+
constructor(db: D1Database, opts?: {
|
|
71
|
+
start?: D1SessionStart;
|
|
72
|
+
});
|
|
43
73
|
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
74
|
+
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
75
|
+
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
76
|
+
* fresh session at it and read its own writes. */
|
|
77
|
+
getBookmark(): string | null;
|
|
44
78
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
45
79
|
}
|
package/dist/runtime/driver.js
CHANGED
|
@@ -18,10 +18,19 @@ function checkIdent(name) {
|
|
|
18
18
|
throw new Error(`invalid identifier: ${name}`);
|
|
19
19
|
return name;
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
22
|
-
*
|
|
21
|
+
/** Render an identifier as a standard double-quoted name (`"order"`), guarding its
|
|
22
|
+
* shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
|
|
23
|
+
* identifiers, so a column/table named after a reserved word (`order`, `group`, …)
|
|
24
|
+
* is safe and case is preserved. The single source of truth for both dialects and
|
|
25
|
+
* the DDL generator — keep every emitted identifier going through this. */
|
|
26
|
+
export function quoteIdent(name) {
|
|
27
|
+
return `"${checkIdent(name)}"`;
|
|
28
|
+
}
|
|
29
|
+
/** SQLite (DO SQLite and D1 both speak this). Double-quoted identifiers (so reserved
|
|
30
|
+
* words like `order` work), `?` placeholders, booleans stored as INTEGER 0/1,
|
|
31
|
+
* RETURNING supported. */
|
|
23
32
|
export const sqliteDialect = {
|
|
24
|
-
id:
|
|
33
|
+
id: quoteIdent,
|
|
25
34
|
placeholder: () => "?",
|
|
26
35
|
returning: true,
|
|
27
36
|
encode: (v) => (typeof v === "boolean" ? (v ? 1 : 0) : v),
|
|
@@ -30,7 +39,7 @@ export const sqliteDialect = {
|
|
|
30
39
|
* `ownerId` doesn't fold to `ownerid`), `$n` placeholders, native booleans,
|
|
31
40
|
* RETURNING supported. */
|
|
32
41
|
export const postgresDialect = {
|
|
33
|
-
id:
|
|
42
|
+
id: quoteIdent,
|
|
34
43
|
placeholder: (n) => `$${n}`,
|
|
35
44
|
returning: true,
|
|
36
45
|
encode: (v) => v, // the pg driver handles type encoding
|
|
@@ -50,20 +59,38 @@ export class DoSqliteDriver {
|
|
|
50
59
|
return this.storage.transaction(fn);
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
|
-
/** D1 — SQLite over RPC. Async by nature.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
62
|
+
/** D1 — SQLite over RPC. Async by nature.
|
|
63
|
+
*
|
|
64
|
+
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
65
|
+
* runs all `exec` through it. Writes in a session always land on the primary; the
|
|
66
|
+
* `start` only chooses where the FIRST read may begin. The session maintains a
|
|
67
|
+
* bookmark (`getBookmark()`) so later reads are sequentially consistent with earlier
|
|
68
|
+
* writes — read-your-writes when the bookmark is threaded across requests.
|
|
69
|
+
*
|
|
70
|
+
* ATOMICITY LIMIT (intentional): D1 has NO interactive transactions — a session can't
|
|
71
|
+
* read mid-`batch()`, and pramen mutations interleave reads + writes + RETURNING +
|
|
72
|
+
* trigger-into-outbox inside one `transaction()`. So `transaction(fn) = fn()`: each
|
|
73
|
+
* statement auto-commits on its own, and a multi-statement mutation does NOT roll back
|
|
74
|
+
* on throw the way it does on a DO. Single-statement mutations are atomic; anything
|
|
75
|
+
* multi-statement is not. Use the DO store when you need atomic mutations. */
|
|
56
76
|
export class D1Driver {
|
|
57
|
-
db;
|
|
58
77
|
dialect = sqliteDialect;
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
session;
|
|
79
|
+
constructor(db, opts) {
|
|
80
|
+
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
61
81
|
}
|
|
62
82
|
async exec(sql, params) {
|
|
63
|
-
const stmt = params.length ? this.
|
|
83
|
+
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
64
84
|
const { results } = await stmt.all();
|
|
65
85
|
return results ?? [];
|
|
66
86
|
}
|
|
87
|
+
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
88
|
+
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
89
|
+
* fresh session at it and read its own writes. */
|
|
90
|
+
getBookmark() {
|
|
91
|
+
return this.session.getBookmark();
|
|
92
|
+
}
|
|
93
|
+
// D1 has no interactive/atomic transactions — see the class doc. Run `fn` as-is.
|
|
67
94
|
transaction(fn) {
|
|
68
95
|
return fn();
|
|
69
96
|
}
|
package/dist/runtime/migrate.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// so it must be declared with `renamedFrom`; otherwise it is applied as drop+add.
|
|
20
20
|
import { addColumnSql, createTableSql, indexStatements, sqlType } from "./ddl";
|
|
21
21
|
import { digest } from "./digest";
|
|
22
|
+
import { quoteIdent } from "./driver";
|
|
22
23
|
import { entitiesInPartition, validateSchema } from "../sdk/schema";
|
|
23
24
|
/** Internal bookkeeping tables the migrator must never touch — pramen's own, SQLite's,
|
|
24
25
|
* and the substrate's (D1 keeps `_cf_*` / `d1_*` tables in sqlite_master and forbids
|
|
@@ -31,11 +32,6 @@ function isInternalTable(name) {
|
|
|
31
32
|
n.startsWith("_cf_") ||
|
|
32
33
|
n.startsWith("d1_"));
|
|
33
34
|
}
|
|
34
|
-
function ident(name) {
|
|
35
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
36
|
-
throw new Error(`invalid identifier: ${name}`);
|
|
37
|
-
return name;
|
|
38
|
-
}
|
|
39
35
|
export function schemaHash(schema) {
|
|
40
36
|
const canon = {};
|
|
41
37
|
for (const [table, def] of Object.entries(schema))
|
|
@@ -45,7 +41,7 @@ export function schemaHash(schema) {
|
|
|
45
41
|
/** Live columns of a table -> their declared SQL type (uppercased). Empty if the
|
|
46
42
|
* table doesn't exist. */
|
|
47
43
|
async function tableColumns(driver, table) {
|
|
48
|
-
const rows = (await driver.exec(`PRAGMA table_info(${
|
|
44
|
+
const rows = (await driver.exec(`PRAGMA table_info(${quoteIdent(table)})`, []));
|
|
49
45
|
return new Map(rows.map((r) => [r.name, (r.type || "").toUpperCase()]));
|
|
50
46
|
}
|
|
51
47
|
async function readMeta(driver, key) {
|
|
@@ -60,7 +56,7 @@ async function writeMeta(driver, key, value) {
|
|
|
60
56
|
* type change; brand-new columns left NULL), drop the old table, rename the temp. */
|
|
61
57
|
async function rebuildTable(driver, table, def, live) {
|
|
62
58
|
const tmp = `__pramen_rebuild_${table}`;
|
|
63
|
-
await driver.exec(`DROP TABLE IF EXISTS ${
|
|
59
|
+
await driver.exec(`DROP TABLE IF EXISTS ${quoteIdent(tmp)}`, []);
|
|
64
60
|
await driver.exec(createTableSql(tmp, def), []);
|
|
65
61
|
const destCols = [];
|
|
66
62
|
const srcExprs = [];
|
|
@@ -70,14 +66,14 @@ async function rebuildTable(driver, table, def, live) {
|
|
|
70
66
|
if (!src)
|
|
71
67
|
continue; // brand-new column with no source -> leave NULL
|
|
72
68
|
const target = sqlType(f);
|
|
73
|
-
destCols.push(
|
|
74
|
-
srcExprs.push(live.get(src) === target ?
|
|
69
|
+
destCols.push(quoteIdent(name));
|
|
70
|
+
srcExprs.push(live.get(src) === target ? quoteIdent(src) : `CAST(${quoteIdent(src)} AS ${target})`);
|
|
75
71
|
}
|
|
76
72
|
if (destCols.length > 0) {
|
|
77
|
-
await driver.exec(`INSERT INTO ${
|
|
73
|
+
await driver.exec(`INSERT INTO ${quoteIdent(tmp)} (${destCols.join(", ")}) SELECT ${srcExprs.join(", ")} FROM ${quoteIdent(table)}`, []);
|
|
78
74
|
}
|
|
79
|
-
await driver.exec(`DROP TABLE ${
|
|
80
|
-
await driver.exec(`ALTER TABLE ${
|
|
75
|
+
await driver.exec(`DROP TABLE ${quoteIdent(table)}`, []);
|
|
76
|
+
await driver.exec(`ALTER TABLE ${quoteIdent(tmp)} RENAME TO ${quoteIdent(table)}`, []);
|
|
81
77
|
}
|
|
82
78
|
export async function migrate(driver, schema, opts = {}) {
|
|
83
79
|
// Static schema invariants (relation targets exist, no cross-partition relations) —
|
|
@@ -133,7 +129,7 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
133
129
|
needsAdditiveRebuild = true;
|
|
134
130
|
continue;
|
|
135
131
|
}
|
|
136
|
-
await driver.exec(`ALTER TABLE ${
|
|
132
|
+
await driver.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${addColumnSql(name, field)}`, []);
|
|
137
133
|
added.push(`${table}.${name}`);
|
|
138
134
|
}
|
|
139
135
|
// Pass 2 — destructive: rebuild if any live column must be dropped, a declared
|
|
@@ -181,7 +177,7 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
181
177
|
if (isInternalTable(name) || inScope.has(name) || otherPartitionTables.has(name))
|
|
182
178
|
continue;
|
|
183
179
|
if (allowDestructive) {
|
|
184
|
-
await driver.exec(`DROP TABLE ${
|
|
180
|
+
await driver.exec(`DROP TABLE ${quoteIdent(name)}`, []);
|
|
185
181
|
droppedTables.push(name);
|
|
186
182
|
}
|
|
187
183
|
else {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Mail } from "./mail";
|
|
2
|
+
import type { Queue } from "./queue";
|
|
3
|
+
import type { Kv } from "./kv";
|
|
4
|
+
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
5
|
+
export interface QueueMessage<Body = unknown> {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly timestamp: Date;
|
|
8
|
+
readonly body: Body;
|
|
9
|
+
/** 1-based delivery attempt — grows on each retry (use it to give up / dead-letter). */
|
|
10
|
+
readonly attempts: number;
|
|
11
|
+
/** Mark this message handled (won't be redelivered). The framework calls this for you
|
|
12
|
+
* when the handler resolves; call it yourself only for fine-grained control. */
|
|
13
|
+
ack(): void;
|
|
14
|
+
/** Schedule this message for redelivery (the framework calls it when the handler throws). */
|
|
15
|
+
retry(options?: {
|
|
16
|
+
delaySeconds?: number;
|
|
17
|
+
}): void;
|
|
18
|
+
}
|
|
19
|
+
/** A batch delivered to the consumer (the Cloudflare Queues `MessageBatch` shape). */
|
|
20
|
+
export interface QueueBatch<Body = unknown> {
|
|
21
|
+
/** The queue this batch came from (the oblaka `Queue` name; env-prefixed remotely). */
|
|
22
|
+
readonly queue: string;
|
|
23
|
+
readonly messages: readonly QueueMessage<Body>[];
|
|
24
|
+
ackAll(): void;
|
|
25
|
+
retryAll(options?: {
|
|
26
|
+
delaySeconds?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
}
|
|
29
|
+
/** The context handed to a queue consumer handler. Worker-level (no `ctx.db`): reach
|
|
30
|
+
* tenant data via `ctx.callPrivileged`. */
|
|
31
|
+
export interface QueueContext {
|
|
32
|
+
/** The Worker environment (bindings + vars + secrets). */
|
|
33
|
+
readonly env: Readonly<Record<string, unknown>>;
|
|
34
|
+
/** Project KV (cross-tenant). */
|
|
35
|
+
readonly kv: Kv;
|
|
36
|
+
/** Send email (the notification path). */
|
|
37
|
+
readonly mail: Mail;
|
|
38
|
+
/** Enqueue onto a (possibly different) queue — fan-out / chaining. */
|
|
39
|
+
readonly queue: Queue;
|
|
40
|
+
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
41
|
+
* The message body should carry the `tenant`. */
|
|
42
|
+
callPrivileged(opts: {
|
|
43
|
+
name: string;
|
|
44
|
+
input?: unknown;
|
|
45
|
+
tenant?: string;
|
|
46
|
+
roles?: string[];
|
|
47
|
+
partition?: string;
|
|
48
|
+
}): Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
|
51
|
+
* throwing RETRIES it (subject to the queue's max_retries → dead-letter queue). */
|
|
52
|
+
export type QueueHandler<Body = unknown> = (ctx: QueueContext, message: QueueMessage<Body>) => void | Promise<void>;
|
|
53
|
+
/** Map of queue name → consumer handler. Set as `app.queues`; dispatched by
|
|
54
|
+
* `createPramen(app).queue`. */
|
|
55
|
+
export type AppQueueMap = Record<string, QueueHandler>;
|
|
56
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
57
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
58
|
+
* leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
|
|
59
|
+
* fall through to it (the common single-queue app). Returns null if nothing matches. */
|
|
60
|
+
export declare function routeQueue(queues: AppQueueMap, queueName: string): QueueHandler | null;
|
|
61
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
62
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
63
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
64
|
+
export declare function dispatchQueueBatch(queues: AppQueueMap, ctx: QueueContext, batch: QueueBatch): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Queue consumer dispatch — the receiving half of ctx.queue. A pramen Worker is the
|
|
2
|
+
// consumer for its declared queues (oblaka `new Queue({ binding: "both", ... })`), so
|
|
3
|
+
// `createPramen(app).queue` is the Cloudflare `queue(batch, env, ctx)` entry. It routes
|
|
4
|
+
// each batch to the matching `app.queues[name]` handler and ACKs/RETRIES per message.
|
|
5
|
+
//
|
|
6
|
+
// A consumer runs in the WORKER, not a Durable Object — a queue message isn't bound to a
|
|
7
|
+
// tenant, so there's no direct `ctx.db`. To touch tenant data, carry the tenant in the
|
|
8
|
+
// message body and `ctx.callPrivileged({ name, input, tenant })` into its DO (exactly
|
|
9
|
+
// like a public route). The consumer still gets `ctx.mail` / `ctx.queue` / `ctx.kv` /
|
|
10
|
+
// `ctx.env`, so the canonical "consume a job → send a notification" path is one call.
|
|
11
|
+
/** Resolve the handler for a batch's queue. Queue names are env-prefixed in remote
|
|
12
|
+
* environments (`production-pramen-jobs`) but bare locally (`pramen-jobs`), so match
|
|
13
|
+
* leniently: exact, then suffix (`…-<key>`), then — if there's exactly one handler —
|
|
14
|
+
* fall through to it (the common single-queue app). Returns null if nothing matches. */
|
|
15
|
+
export function routeQueue(queues, queueName) {
|
|
16
|
+
const keys = Object.keys(queues);
|
|
17
|
+
if (queues[queueName])
|
|
18
|
+
return queues[queueName];
|
|
19
|
+
const suffix = keys.find((k) => queueName.endsWith(`-${k}`) || k.endsWith(`-${queueName}`));
|
|
20
|
+
if (suffix)
|
|
21
|
+
return queues[suffix];
|
|
22
|
+
if (keys.length === 1)
|
|
23
|
+
return queues[keys[0]];
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** Dispatch one batch: route to the handler, then run it per message, ACKing on success
|
|
27
|
+
* and RETRYing on throw (per message, so one poison message doesn't re-deliver the rest).
|
|
28
|
+
* An unrouted batch is retried whole (never silently acked) and logged. */
|
|
29
|
+
export async function dispatchQueueBatch(queues, ctx, batch) {
|
|
30
|
+
const handler = routeQueue(queues, batch.queue);
|
|
31
|
+
if (!handler) {
|
|
32
|
+
console.error(`pramen: no app.queues handler for queue '${batch.queue}' — retrying batch (declare it in app.queues)`);
|
|
33
|
+
batch.retryAll();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await Promise.all(batch.messages.map(async (message) => {
|
|
37
|
+
try {
|
|
38
|
+
await handler(ctx, message);
|
|
39
|
+
message.ack();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error(`pramen: queue '${batch.queue}' message ${message.id} failed (attempt ${message.attempts}) — retrying`, err);
|
|
43
|
+
message.retry();
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
2
|
+
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
3
|
+
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
4
|
+
/** Per-message send options (mirrors the Cloudflare Queues producer API). */
|
|
5
|
+
export interface QueueSendOptions {
|
|
6
|
+
/** Defer delivery by N seconds (the consumer won't see the message until then). */
|
|
7
|
+
delaySeconds?: number;
|
|
8
|
+
/** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
|
|
9
|
+
contentType?: QueueContentType;
|
|
10
|
+
}
|
|
11
|
+
/** One message in a `sendBatch` — a body plus its own per-message options. */
|
|
12
|
+
export interface QueueSendRequest {
|
|
13
|
+
body: unknown;
|
|
14
|
+
delaySeconds?: number;
|
|
15
|
+
contentType?: QueueContentType;
|
|
16
|
+
}
|
|
17
|
+
/** Batch-level send options. */
|
|
18
|
+
export interface QueueBatchOptions {
|
|
19
|
+
/** Default delay applied to every message in the batch (per-message overrides win). */
|
|
20
|
+
delaySeconds?: number;
|
|
21
|
+
}
|
|
22
|
+
/** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
|
|
23
|
+
* is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
|
|
24
|
+
* distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
|
|
25
|
+
export interface QueueProducerBinding {
|
|
26
|
+
send(body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
27
|
+
sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
|
|
30
|
+
export interface QueueAdapter {
|
|
31
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
32
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
|
|
35
|
+
export declare class Queue {
|
|
36
|
+
private readonly adapter;
|
|
37
|
+
constructor(adapter: QueueAdapter);
|
|
38
|
+
/** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
|
|
39
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
40
|
+
/** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
|
|
41
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** Cloudflare Queues transport. Constructed with the producer bindings discovered from
|
|
44
|
+
* the environment, keyed by binding name. Sending to a name with no bound queue throws
|
|
45
|
+
* a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
|
|
46
|
+
export declare class CloudflareQueueAdapter implements QueueAdapter {
|
|
47
|
+
private readonly bindings;
|
|
48
|
+
constructor(bindings: Readonly<Record<string, QueueProducerBinding>>);
|
|
49
|
+
private bindingFor;
|
|
50
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
51
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** In-memory transport: captures sent messages instead of delivering them. For unit
|
|
54
|
+
* tests (assert on `.sent`) and pure off-platform use. */
|
|
55
|
+
export declare class MemoryQueueAdapter implements QueueAdapter {
|
|
56
|
+
readonly sent: Array<{
|
|
57
|
+
queue: string;
|
|
58
|
+
body: unknown;
|
|
59
|
+
options?: QueueSendOptions | QueueBatchOptions;
|
|
60
|
+
}>;
|
|
61
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
62
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
65
|
+
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
66
|
+
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
67
|
+
export declare function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding>;
|
|
68
|
+
/** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
|
|
69
|
+
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
70
|
+
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
71
|
+
* dev (lopata) and miniflare too. */
|
|
72
|
+
export declare function createQueue(env: Readonly<Record<string, unknown>>): Queue;
|