@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ctx.queue — Cloudflare Queues producer facade, the same shape as ctx.mail / ctx.files:
|
|
2
|
+
// an adapter seam (CloudflareQueueAdapter / MemoryQueueAdapter) behind a thin `Queue`
|
|
3
|
+
// facade, built from the environment. Handlers enqueue onto a native Cloudflare Queue
|
|
4
|
+
// without touching the producer binding directly:
|
|
5
|
+
//
|
|
6
|
+
// await ctx.queue.send("jobs", { kind: "resize", id });
|
|
7
|
+
// await ctx.queue.sendBatch("jobs", [{ body: a }, { body: b, delaySeconds: 30 }]);
|
|
8
|
+
//
|
|
9
|
+
// This is distinct from `ctx.tasks` (the transactional outbox). `ctx.tasks.enqueue`
|
|
10
|
+
// is atomic with the mutation's DB write (commit-or-rollback together) and drained
|
|
11
|
+
// in-process. `ctx.queue` is a native Cloudflare Queue: NOT transactional with the
|
|
12
|
+
// write (the message is sent regardless of whether the mutation later rolls back),
|
|
13
|
+
// but higher-throughput, with platform-native batching/retry/DLQ and a consumer that
|
|
14
|
+
// can run in a *different* Worker. Reach for ctx.tasks when the side-effect must commit
|
|
15
|
+
// with the data; reach for ctx.queue for decoupled, high-volume fan-out.
|
|
16
|
+
//
|
|
17
|
+
// On Cloudflare the transport is the Queues producer binding (declared in oblaka.ts as
|
|
18
|
+
// `new Queue({ binding: "both", ... })`). Off-platform / unconfigured, sending to a queue
|
|
19
|
+
// that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
|
|
20
|
+
// mirroring how ctx.mail fails closed without a transport.
|
|
21
|
+
|
|
22
|
+
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
23
|
+
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
24
|
+
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
25
|
+
|
|
26
|
+
/** Per-message send options (mirrors the Cloudflare Queues producer API). */
|
|
27
|
+
export interface QueueSendOptions {
|
|
28
|
+
/** Defer delivery by N seconds (the consumer won't see the message until then). */
|
|
29
|
+
delaySeconds?: number;
|
|
30
|
+
/** How the body is serialized on the wire. Omitted ⇒ platform default (v8). */
|
|
31
|
+
contentType?: QueueContentType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One message in a `sendBatch` — a body plus its own per-message options. */
|
|
35
|
+
export interface QueueSendRequest {
|
|
36
|
+
body: unknown;
|
|
37
|
+
delaySeconds?: number;
|
|
38
|
+
contentType?: QueueContentType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Batch-level send options. */
|
|
42
|
+
export interface QueueBatchOptions {
|
|
43
|
+
/** Default delay applied to every message in the batch (per-message overrides win). */
|
|
44
|
+
delaySeconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Cloudflare Queues producer binding shape (what `env.<QUEUE>` exposes). A binding
|
|
48
|
+
* is recognized as a queue producer iff it has BOTH `send` and `sendBatch` (which
|
|
49
|
+
* distinguishes it from the email `send`-only binding, KV, R2, D1, …). */
|
|
50
|
+
export interface QueueProducerBinding {
|
|
51
|
+
send(body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
52
|
+
sendBatch(messages: Iterable<QueueSendRequest>, options?: QueueBatchOptions): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The transport seam — one per backend (Cloudflare Queues, an in-memory capture, …). */
|
|
56
|
+
export interface QueueAdapter {
|
|
57
|
+
send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void>;
|
|
58
|
+
sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `ctx.queue` facade: validates, then delegates to the adapter for a named queue. */
|
|
62
|
+
export class Queue {
|
|
63
|
+
constructor(private readonly adapter: QueueAdapter) {}
|
|
64
|
+
|
|
65
|
+
/** Enqueue a single message onto `queue`. `body` is serialized by the platform. */
|
|
66
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
67
|
+
assertQueueName(queue);
|
|
68
|
+
if (body === undefined) throw new Error("ctx.queue.send: `body` is required");
|
|
69
|
+
await this.adapter.send(queue, body, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Enqueue many messages onto `queue` in one call (cheaper than N sends). */
|
|
73
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
74
|
+
assertQueueName(queue);
|
|
75
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
76
|
+
throw new Error("ctx.queue.sendBatch: `messages` must be a non-empty array");
|
|
77
|
+
}
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || m.body === undefined) throw new Error("ctx.queue.sendBatch: every message needs a `body`");
|
|
80
|
+
}
|
|
81
|
+
await this.adapter.sendBatch(queue, messages, options);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertQueueName(queue: string): void {
|
|
86
|
+
if (typeof queue !== "string" || queue.length === 0) {
|
|
87
|
+
throw new Error("ctx.queue: a queue name is required (the oblaka `Queue` name)");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cloudflare Queues transport. Constructed with the producer bindings discovered from
|
|
92
|
+
* the environment, keyed by binding name. Sending to a name with no bound queue throws
|
|
93
|
+
* a clear error (fail-closed) — a missing binding is a config error, not a silent drop. */
|
|
94
|
+
export class CloudflareQueueAdapter implements QueueAdapter {
|
|
95
|
+
constructor(private readonly bindings: Readonly<Record<string, QueueProducerBinding>>) {}
|
|
96
|
+
|
|
97
|
+
private bindingFor(queue: string): QueueProducerBinding {
|
|
98
|
+
const b = this.bindings[queue];
|
|
99
|
+
if (!b) {
|
|
100
|
+
const known = Object.keys(this.bindings);
|
|
101
|
+
const avail = known.length ? known.join(", ") : "none";
|
|
102
|
+
throw new Error(
|
|
103
|
+
`ctx.queue: no queue binding '${queue}' — declare it in oblaka.ts ` +
|
|
104
|
+
`(new Queue({ name: '${queue}', binding: 'both' })). Bound queues: ${avail}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return b;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
111
|
+
await this.bindingFor(queue).send(body, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
115
|
+
await this.bindingFor(queue).sendBatch(messages, options);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** In-memory transport: captures sent messages instead of delivering them. For unit
|
|
120
|
+
* tests (assert on `.sent`) and pure off-platform use. */
|
|
121
|
+
export class MemoryQueueAdapter implements QueueAdapter {
|
|
122
|
+
readonly sent: Array<{ queue: string; body: unknown; options?: QueueSendOptions | QueueBatchOptions }> = [];
|
|
123
|
+
async send(queue: string, body: unknown, options?: QueueSendOptions): Promise<void> {
|
|
124
|
+
this.sent.push({ queue, body, options });
|
|
125
|
+
}
|
|
126
|
+
async sendBatch(queue: string, messages: readonly QueueSendRequest[], options?: QueueBatchOptions): Promise<void> {
|
|
127
|
+
for (const m of messages) this.sent.push({ queue, body: m.body, options: { ...options, ...m } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
132
|
+
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
133
|
+
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
134
|
+
export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
|
|
135
|
+
const out: Record<string, QueueProducerBinding> = {};
|
|
136
|
+
for (const [name, value] of Object.entries(env)) {
|
|
137
|
+
if (
|
|
138
|
+
value &&
|
|
139
|
+
typeof value === "object" &&
|
|
140
|
+
typeof (value as { send?: unknown }).send === "function" &&
|
|
141
|
+
typeof (value as { sendBatch?: unknown }).sendBatch === "function"
|
|
142
|
+
) {
|
|
143
|
+
out[name] = value as QueueProducerBinding;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
|
|
150
|
+
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
151
|
+
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
152
|
+
* dev (lopata) and miniflare too. */
|
|
153
|
+
export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
|
|
154
|
+
return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
|
|
155
|
+
}
|
|
@@ -91,7 +91,12 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
|
|
|
91
91
|
case "false":
|
|
92
92
|
return { sql: "0", params };
|
|
93
93
|
case "cmp":
|
|
94
|
-
|
|
94
|
+
// A comparison against NULL is never TRUE in SQL (=, !=, <, > all yield NULL).
|
|
95
|
+
// Only the dedicated `null` node produces `IS NULL`; a `cmp` with a null operand
|
|
96
|
+
// matches nothing. (`eq()` already routes an equality-to-null to the `null` node,
|
|
97
|
+
// and the keyset comparator handles null order-keys explicitly — so no legitimate
|
|
98
|
+
// caller reaches here with a null value.)
|
|
99
|
+
if (expr.value === null) return { sql: "0", params };
|
|
95
100
|
params.push(dialect.encode(expr.value));
|
|
96
101
|
return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
|
|
97
102
|
case "null":
|
|
@@ -145,7 +150,7 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
|
|
|
145
150
|
return false;
|
|
146
151
|
case "cmp": {
|
|
147
152
|
const left = bind(row[expr.col]);
|
|
148
|
-
if (expr.value === null) return
|
|
153
|
+
if (expr.value === null) return false; // comparison against NULL is never true (use the `null` node for IS NULL)
|
|
149
154
|
if (left === null || left === undefined) return false; // NULL compared to a value -> false
|
|
150
155
|
const right = bind(expr.value);
|
|
151
156
|
switch (expr.op) {
|
|
@@ -1,56 +1,170 @@
|
|
|
1
|
-
// Schema shape + diff — powers the CLI's `schema diff`.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
1
|
+
// Schema shape + diff — powers the CLI's `schema diff`. The diff is a REPORTING tool; it
|
|
2
|
+
// does not itself migrate. On the next DO boot migrate() applies ADDITIVE changes only
|
|
3
|
+
// (new table -> CREATE TABLE; new column -> ALTER TABLE ADD COLUMN). DESTRUCTIVE changes
|
|
4
|
+
// (drop column/table, type change, table rebuild) are SKIPPED unless the deploy sets
|
|
5
|
+
// PRAMEN_ALLOW_DESTRUCTIVE=true — and when skipped the schema hash is left unwritten so a
|
|
6
|
+
// later opt-in deploy retries. A rename can't be detected from a shape diff; it shows as
|
|
7
|
+
// drop+add unless declared with `renamedFrom` in the schema.
|
|
8
|
+
//
|
|
9
|
+
// The shape records column type + the migration-relevant modifiers (notNull, unique,
|
|
10
|
+
// primaryKey, generated, default, hidden) and the entity's partition, so the diff REPORTS
|
|
11
|
+
// modifier and partition changes too. migrate() now RECONCILES modifier changes on an
|
|
12
|
+
// existing column (a NOT NULL / DEFAULT / PRIMARY KEY change via a rebuild, a UNIQUE
|
|
13
|
+
// change via a create/drop index), so a `change-column` is `appliesOnBoot: true`. It is
|
|
14
|
+
// flagged `destructive` when it tightens a constraint (adds NOT NULL / UNIQUE / PRIMARY
|
|
15
|
+
// KEY) — those apply only under PRAMEN_ALLOW_DESTRUCTIVE, or are skipped when the live
|
|
16
|
+
// data conflicts (NULL rows / duplicates), leaving the hash unwritten. A partition MOVE
|
|
17
|
+
// still CANNOT be enacted on boot (a partition is a separate Durable Object — it needs a
|
|
18
|
+
// manual cross-DO data migration), so it stays `appliesOnBoot: false`.
|
|
6
19
|
|
|
7
20
|
import type { FieldDef, SchemaDef } from "../sdk/schema";
|
|
21
|
+
import { partitionOf } from "../sdk/schema";
|
|
8
22
|
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
23
|
+
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
24
|
+
export interface ColumnShape {
|
|
25
|
+
type: string;
|
|
26
|
+
notNull?: boolean;
|
|
27
|
+
unique?: boolean;
|
|
28
|
+
primaryKey?: boolean;
|
|
29
|
+
generated?: boolean;
|
|
30
|
+
hidden?: boolean;
|
|
31
|
+
/** The literal or raw-SQL default, normalized to a string for comparison. */
|
|
32
|
+
default?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
36
|
+
export interface TableShape {
|
|
37
|
+
partition: string;
|
|
38
|
+
columns: Record<string, ColumnShape>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** table -> table shape. The comparable surface of a schema. */
|
|
42
|
+
export type SchemaShape = Record<string, TableShape>;
|
|
43
|
+
|
|
44
|
+
function columnShape(f: FieldDef): ColumnShape {
|
|
45
|
+
const c: ColumnShape = { type: f.type };
|
|
46
|
+
if (f.notNull) c.notNull = true;
|
|
47
|
+
if (f.unique) c.unique = true;
|
|
48
|
+
if (f.primaryKey) c.primaryKey = true;
|
|
49
|
+
if (f.generated) c.generated = true;
|
|
50
|
+
if (f.hidden) c.hidden = true;
|
|
51
|
+
if (f.defaultExpr !== undefined) c.default = `(${f.defaultExpr})`;
|
|
52
|
+
else if (f.default !== undefined) c.default = JSON.stringify(f.default);
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
11
55
|
|
|
12
56
|
export function schemaShape(schema: SchemaDef): SchemaShape {
|
|
13
57
|
const out: SchemaShape = {};
|
|
14
58
|
for (const [table, def] of Object.entries(schema)) {
|
|
15
|
-
const
|
|
16
|
-
for (const [col, f] of Object.entries(def.fields))
|
|
17
|
-
out[table] =
|
|
59
|
+
const columns: Record<string, ColumnShape> = {};
|
|
60
|
+
for (const [col, f] of Object.entries(def.fields)) columns[col] = columnShape(f as FieldDef);
|
|
61
|
+
out[table] = { partition: partitionOf(schema, table), columns };
|
|
18
62
|
}
|
|
19
63
|
return out;
|
|
20
64
|
}
|
|
21
65
|
|
|
66
|
+
/** The modifier fields compared for a `change-column` (everything but `type`). */
|
|
67
|
+
const MODIFIER_KEYS: (keyof ColumnShape)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
|
|
68
|
+
|
|
69
|
+
/** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
|
|
70
|
+
* Such a change may require the destructive gate or be skipped when the live data
|
|
71
|
+
* conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
|
|
72
|
+
function tightensConstraint(prev: ColumnShape, next: ColumnShape): boolean {
|
|
73
|
+
return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function modifierDiff(prev: ColumnShape, next: ColumnShape): string | null {
|
|
77
|
+
const parts: string[] = [];
|
|
78
|
+
for (const k of MODIFIER_KEYS) {
|
|
79
|
+
if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
|
|
80
|
+
}
|
|
81
|
+
return parts.length ? parts.join(", ") : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fmt(v: unknown): string {
|
|
85
|
+
return v === undefined ? "—" : String(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
22
88
|
export interface SchemaChange {
|
|
23
|
-
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type";
|
|
89
|
+
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
|
|
24
90
|
table: string;
|
|
25
91
|
column?: string;
|
|
26
92
|
detail?: string;
|
|
27
|
-
/** true = rebuilds the table and may lose data (drop / type change)
|
|
28
|
-
*
|
|
93
|
+
/** true = rebuilds the table and may lose data (drop / type change). false = additive
|
|
94
|
+
* OR a metadata-only change (modifier / partition move) — see `appliesOnBoot`. */
|
|
29
95
|
destructive: boolean;
|
|
96
|
+
/** Whether migrate() enacts this change on the next DO boot. Additive changes are
|
|
97
|
+
* always applied; destructive changes (type/drop, or a constraint-tightening modifier
|
|
98
|
+
* change) apply only when the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true (and are
|
|
99
|
+
* skipped when the live data conflicts, leaving the hash unwritten). `false` here means
|
|
100
|
+
* the boot migrator will NEVER enact it — today only a partition MOVE (needs a manual
|
|
101
|
+
* cross-DO data migration). Reported for honesty. */
|
|
102
|
+
appliesOnBoot: boolean;
|
|
30
103
|
}
|
|
31
104
|
|
|
32
105
|
export function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[] {
|
|
33
106
|
const changes: SchemaChange[] = [];
|
|
34
107
|
|
|
35
108
|
for (const table of Object.keys(next)) {
|
|
36
|
-
|
|
37
|
-
|
|
109
|
+
const pt = prev[table];
|
|
110
|
+
if (!pt) {
|
|
111
|
+
changes.push({ kind: "add-table", table, destructive: false, appliesOnBoot: true });
|
|
38
112
|
continue;
|
|
39
113
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
114
|
+
const nt = next[table]!;
|
|
115
|
+
if (pt.partition !== nt.partition) {
|
|
116
|
+
changes.push({
|
|
117
|
+
kind: "move-partition",
|
|
118
|
+
table,
|
|
119
|
+
detail: `${pt.partition} → ${nt.partition}`,
|
|
120
|
+
destructive: false,
|
|
121
|
+
// A partition is a separate Durable Object; boot migration can't move a table's
|
|
122
|
+
// data across DOs. Needs a manual data migration.
|
|
123
|
+
appliesOnBoot: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
for (const col of Object.keys(nt.columns)) {
|
|
127
|
+
const pc = pt.columns[col];
|
|
128
|
+
const ncol = nt.columns[col]!;
|
|
129
|
+
if (!pc) {
|
|
130
|
+
changes.push({ kind: "add-column", table, column: col, destructive: false, appliesOnBoot: true });
|
|
131
|
+
} else if (pc.type !== ncol.type) {
|
|
132
|
+
changes.push({
|
|
133
|
+
kind: "change-type",
|
|
134
|
+
table,
|
|
135
|
+
column: col,
|
|
136
|
+
detail: `${pc.type} → ${ncol.type}`,
|
|
137
|
+
destructive: true,
|
|
138
|
+
// Applied only under PRAMEN_ALLOW_DESTRUCTIVE (a table rebuild). Report it as
|
|
139
|
+
// boot-applicable — the destructive-gating note explains the opt-in.
|
|
140
|
+
appliesOnBoot: true,
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
const md = modifierDiff(pc, ncol);
|
|
144
|
+
if (md) {
|
|
145
|
+
changes.push({
|
|
146
|
+
kind: "change-column",
|
|
147
|
+
table,
|
|
148
|
+
column: col,
|
|
149
|
+
detail: md,
|
|
150
|
+
// Tightening a constraint (add NOT NULL / UNIQUE / PRIMARY KEY) rebuilds/
|
|
151
|
+
// indexes and applies only under PRAMEN_ALLOW_DESTRUCTIVE (or is skipped when
|
|
152
|
+
// live data conflicts). Loosening or a DEFAULT change is additive.
|
|
153
|
+
destructive: tightensConstraint(pc, ncol),
|
|
154
|
+
// migrate() now reconciles modifier changes on an existing column on boot.
|
|
155
|
+
appliesOnBoot: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
45
158
|
}
|
|
46
159
|
}
|
|
47
|
-
for (const col of Object.keys(
|
|
48
|
-
if (!(col in
|
|
160
|
+
for (const col of Object.keys(pt.columns)) {
|
|
161
|
+
if (!(col in nt.columns))
|
|
162
|
+
changes.push({ kind: "drop-column", table, column: col, destructive: true, appliesOnBoot: true });
|
|
49
163
|
}
|
|
50
164
|
}
|
|
51
165
|
|
|
52
166
|
for (const table of Object.keys(prev)) {
|
|
53
|
-
if (!(table in next)) changes.push({ kind: "drop-table", table, destructive: true });
|
|
167
|
+
if (!(table in next)) changes.push({ kind: "drop-table", table, destructive: true, appliesOnBoot: true });
|
|
54
168
|
}
|
|
55
169
|
|
|
56
170
|
return changes;
|
package/src/runtime/storage.ts
CHANGED
|
Binary file
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Db } from "../runtime/db";
|
|
6
6
|
import type { Kv } from "../runtime/kv";
|
|
7
7
|
import type { Mail } from "../runtime/mail";
|
|
8
|
+
import type { Queue } from "../runtime/queue";
|
|
8
9
|
import type { Identity } from "./acl";
|
|
9
10
|
import type { Files } from "./files";
|
|
10
11
|
import type { SchemaDef } from "./schema";
|
|
@@ -36,6 +37,12 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
36
37
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
37
38
|
* retry. For notification email, webhooks, etc. — see `app.tasks`. */
|
|
38
39
|
readonly tasks: Tasks;
|
|
40
|
+
/** Enqueue onto a native Cloudflare Queue: `ctx.queue.send("jobs", body)`. Unlike
|
|
41
|
+
* `ctx.tasks` (a transactional outbox, atomic with the mutation, drained in-process),
|
|
42
|
+
* a queue send is NOT transactional with the write but is higher-throughput, with
|
|
43
|
+
* platform-native batching/retry/DLQ and a consumer that may live in another Worker.
|
|
44
|
+
* Declare queues in oblaka.ts; consume them via `app.queues`. */
|
|
45
|
+
readonly queue: Queue;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
|