@pramen/server 0.0.58 → 0.0.60
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/cli.js +99 -2
- package/dist/durable-object.d.ts +2 -0
- package/dist/durable-object.js +48 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/pramen.d.ts +7 -1
- package/dist/pramen.js +2 -0
- package/dist/runtime/data-migrations.d.ts +88 -0
- package/dist/runtime/data-migrations.js +256 -0
- package/dist/runtime/mail.d.ts +34 -2
- package/dist/runtime/mail.js +82 -9
- package/dist/sdk/acl.d.ts +13 -5
- package/dist/sdk/acl.js +13 -5
- package/dist/sdk/handlers.d.ts +41 -0
- package/dist/sdk/iso-timestamps.d.ts +54 -0
- package/dist/sdk/iso-timestamps.js +119 -0
- package/dist/sdk/schema.d.ts +39 -3
- package/dist/sdk/schema.js +40 -4
- package/dist/worker.js +88 -1
- package/package.json +1 -1
- package/src/cli.ts +125 -4
- package/src/durable-object.ts +53 -0
- package/src/index.ts +6 -3
- package/src/pramen.ts +9 -1
- package/src/runtime/data-migrations.ts +343 -0
- package/src/runtime/mail.ts +80 -9
- package/src/sdk/acl.ts +13 -5
- package/src/sdk/handlers.ts +43 -0
- package/src/sdk/iso-timestamps.ts +146 -0
- package/src/sdk/schema.ts +41 -4
- package/src/worker.ts +89 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Rewriting timestamps written before `expr.now()` emitted ISO-8601.
|
|
2
|
+
//
|
|
3
|
+
// `expr.now()` used to emit `datetime('now')` — the `CURRENT_TIMESTAMP` space form,
|
|
4
|
+
// `'2026-09-03 21:33:07'`. It now emits `'2026-09-03T21:33:07.222Z'`. Changing a column's
|
|
5
|
+
// DEFAULT is a MODIFIER change, so `migrate()` rebuilds the table — and a rebuild copies
|
|
6
|
+
// existing values through untouched. New rows would get the new shape and old rows would
|
|
7
|
+
// keep the old one, in the same column.
|
|
8
|
+
//
|
|
9
|
+
// That is worse than either format alone, and the failure is narrower and nastier than
|
|
10
|
+
// "the column is inconsistent". Both forms open with the same `YYYY-MM-DD`, so for values
|
|
11
|
+
// on DIFFERENT dates the date prefix decides and ordering is still correct. It is values on
|
|
12
|
+
// the SAME date that break: index 10 is ` ` (0x20) in the space form and `T` (0x54) in ISO,
|
|
13
|
+
// so a same-day space-form value always sorts BEFORE a same-day ISO one whatever the
|
|
14
|
+
// time-of-day — 23:00 in the old shape sorts below 01:00 in the new.
|
|
15
|
+
//
|
|
16
|
+
// Which makes the deploy window itself the blast radius: the rows written just before and
|
|
17
|
+
// just after the change are exactly the same-day pairs that invert. And a `{ lte: $now() }`
|
|
18
|
+
// policy is the sharp version, because `$now()` is ISO: a space-form value dated today
|
|
19
|
+
// always compares as less than it, so a row scheduled for later TODAY reads as already
|
|
20
|
+
// published until midnight. Nothing reports any of it.
|
|
21
|
+
//
|
|
22
|
+
// So the format change comes with a rewrite, and the rewrite is a DATA migration: it is
|
|
23
|
+
// imperative, it must run exactly once, and it must fail closed. Declaring it is the app's
|
|
24
|
+
// job — this builds it.
|
|
25
|
+
|
|
26
|
+
import { DEFAULT_PARTITION, ISO_NOW_SQL, entitiesInPartition } from "./schema";
|
|
27
|
+
import type { DataMigration, MigrationContext } from "./handlers";
|
|
28
|
+
import type { FieldDef, SchemaDef } from "./schema";
|
|
29
|
+
|
|
30
|
+
/** The old default's SQL, for recognizing a column that carried it. */
|
|
31
|
+
export const LEGACY_NOW_SQL = "datetime('now')";
|
|
32
|
+
|
|
33
|
+
/** Columns to rewrite beyond the ones the schema identifies, as `{ table: [column, …] }`.
|
|
34
|
+
*
|
|
35
|
+
* The schema can only find columns whose DEFAULT is `expr.now()`. A column written by
|
|
36
|
+
* HANDLER code in the same space form — `@pramen/cms` stamped `cms_pages.publishedAt` that
|
|
37
|
+
* way — has no marker on it at all, so the app has to name it. `@pramen/cms` exports its
|
|
38
|
+
* own set as `CMS_LEGACY_TIMESTAMP_COLUMNS`. */
|
|
39
|
+
export type ExtraTimestampColumns = Readonly<Record<string, readonly string[]>>;
|
|
40
|
+
|
|
41
|
+
export interface IsoTimestampBackfillOpts {
|
|
42
|
+
/** Ledger id. Defaults to `pramen:iso-timestamps`. Override only if you have already
|
|
43
|
+
* used that id for something else — it is the key that makes this run once. */
|
|
44
|
+
id?: string;
|
|
45
|
+
/** Partition to run in. A partition-DO only sees its own tables, so an app with several
|
|
46
|
+
* partitions declares one of these per partition, each with its own id. */
|
|
47
|
+
partition?: string;
|
|
48
|
+
/** Columns the schema cannot identify — see {@link ExtraTimestampColumns}. */
|
|
49
|
+
extraColumns?: ExtraTimestampColumns;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `DataMigration` that rewrites space-form timestamps to ISO-8601 in place.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { isoTimestampBackfill } from "@pramen/server";
|
|
57
|
+
* import { CMS_LEGACY_TIMESTAMP_COLUMNS } from "@pramen/cms";
|
|
58
|
+
*
|
|
59
|
+
* export const app = {
|
|
60
|
+
* migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })],
|
|
61
|
+
* // …
|
|
62
|
+
* };
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* A **new** deployment can declare it too and pay nothing: every `UPDATE` matches no rows.
|
|
66
|
+
* Declaring it unconditionally is the cheaper habit, because "was this store ever written
|
|
67
|
+
* by an older build?" is not a question the code can answer later.
|
|
68
|
+
*
|
|
69
|
+
* It is not idempotent-by-accident, it is idempotent-by-shape: the `WHERE` matches only the
|
|
70
|
+
* space form, and what it writes is not the space form. That matters because a data
|
|
71
|
+
* migration is not required to be idempotent (it is recorded once) but this one runs over
|
|
72
|
+
* columns an app may also be writing, and a second pass must not corrupt a converted value.
|
|
73
|
+
*/
|
|
74
|
+
export function isoTimestampBackfill(opts: IsoTimestampBackfillOpts = {}): DataMigration {
|
|
75
|
+
const partition = opts.partition ?? DEFAULT_PARTITION;
|
|
76
|
+
return {
|
|
77
|
+
id: opts.id ?? "pramen:iso-timestamps",
|
|
78
|
+
partition,
|
|
79
|
+
async up(ctx: MigrationContext): Promise<void> {
|
|
80
|
+
for (const [table, columns] of timestampColumns(ctx.schema, partition, opts.extraColumns)) {
|
|
81
|
+
for (const column of columns) {
|
|
82
|
+
// A bulk UPDATE, not a walk: this runs inside `blockConcurrencyWhile` on a
|
|
83
|
+
// tenant's first fetch, so a row-by-row rewrite of a large table is what stalls
|
|
84
|
+
// that request and risks the DO's wall-clock limit.
|
|
85
|
+
//
|
|
86
|
+
// The predicate is deliberately exact rather than "does not look like ISO":
|
|
87
|
+
// length 19 with a space at index 11 (SQLite's `substr` is 1-based) is the space
|
|
88
|
+
// form and nothing else. A column holding some third format an app wrote by hand
|
|
89
|
+
// is left alone, because guessing at it would be a silent rewrite of data this
|
|
90
|
+
// migration does not understand.
|
|
91
|
+
await ctx.driver.exec(
|
|
92
|
+
`UPDATE ${q(table)} SET ${q(column)} = replace(${q(column)}, ' ', 'T') || '.000Z' ` +
|
|
93
|
+
`WHERE ${q(column)} IS NOT NULL AND length(${q(column)}) = 19 AND substr(${q(column)}, 11, 1) = ' '`,
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which `(table, column)` pairs this migration touches — the schema's `expr.now()` columns
|
|
104
|
+
* in `partition`, plus whatever the app named.
|
|
105
|
+
*
|
|
106
|
+
* Exported for the tests and for anyone who wants to see the list before running it. An
|
|
107
|
+
* extra column naming a table or column that is not in the schema is IGNORED rather than
|
|
108
|
+
* throwing: the list is a hand-written constant an app may keep across a schema change, and
|
|
109
|
+
* failing the whole boot over a stale entry in it would be a worse outcome than skipping it.
|
|
110
|
+
*/
|
|
111
|
+
export function timestampColumns(
|
|
112
|
+
schema: SchemaDef,
|
|
113
|
+
partition: string = DEFAULT_PARTITION,
|
|
114
|
+
extra: ExtraTimestampColumns = {},
|
|
115
|
+
): Map<string, string[]> {
|
|
116
|
+
const out = new Map<string, string[]>();
|
|
117
|
+
const inPartition = new Set(entitiesInPartition(schema, partition));
|
|
118
|
+
for (const table of inPartition) {
|
|
119
|
+
const fields = schema[table]?.fields as Record<string, FieldDef> | undefined;
|
|
120
|
+
if (!fields) continue;
|
|
121
|
+
const cols = Object.entries(fields)
|
|
122
|
+
// Both spellings: a store written by an older build has the legacy default recorded
|
|
123
|
+
// in its own DDL, and `migrate()` may not have rebuilt the table yet when this runs
|
|
124
|
+
// (it does — migrations run after — but the check costs nothing and makes the set
|
|
125
|
+
// independent of that ordering).
|
|
126
|
+
.filter(([, f]) => f.defaultExpr === ISO_NOW_SQL || f.defaultExpr === LEGACY_NOW_SQL)
|
|
127
|
+
.map(([name]) => name);
|
|
128
|
+
if (cols.length > 0) out.set(table, cols);
|
|
129
|
+
}
|
|
130
|
+
for (const [table, columns] of Object.entries(extra)) {
|
|
131
|
+
if (!inPartition.has(table)) continue;
|
|
132
|
+
const fields = schema[table]?.fields as Record<string, FieldDef> | undefined;
|
|
133
|
+
if (!fields) continue;
|
|
134
|
+
const existing = out.get(table) ?? [];
|
|
135
|
+
for (const column of columns) {
|
|
136
|
+
if (fields[column] && !existing.includes(column)) existing.push(column);
|
|
137
|
+
}
|
|
138
|
+
if (existing.length > 0) out.set(table, existing);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Double-quote an identifier for SQLite. Table and column names here come from the app's
|
|
144
|
+
* own schema, never from a request — but the SQL is built by concatenation, so they are
|
|
145
|
+
* quoted rather than trusted to be quote-free. */
|
|
146
|
+
const q = (ident: string): string => `"${ident.replace(/"/g, '""')}"`;
|
package/src/sdk/schema.ts
CHANGED
|
@@ -257,11 +257,48 @@ export class ExprDefault {
|
|
|
257
257
|
constructor(readonly sql: string) {}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
260
|
+
/**
|
|
261
|
+
* The SQL `expr.now()` emits: the current UTC instant as **ISO-8601 TEXT with
|
|
262
|
+
* milliseconds and a `Z`** — `'2026-09-03T21:33:07.222Z'`, byte-for-byte what
|
|
263
|
+
* `new Date().toISOString()` produces.
|
|
264
|
+
*
|
|
265
|
+
* Exported because three things have to agree on it and none of them can see the others:
|
|
266
|
+
* the DDL that writes the default, {@link isoTimestampBackfill} which rewrites rows
|
|
267
|
+
* written before this format, and the migration diff that decides a column's DEFAULT
|
|
268
|
+
* changed at all.
|
|
269
|
+
*/
|
|
270
|
+
export const ISO_NOW_SQL = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* SQL-expression defaults for `defaultTo(field, expr.now())`.
|
|
274
|
+
*
|
|
275
|
+
* `now()` is the current UTC instant as ISO-8601 TEXT (`'2026-09-03T21:33:07.222Z'`) —
|
|
276
|
+
* pair it with `t.text()`.
|
|
277
|
+
*
|
|
278
|
+
* **It used to emit `datetime('now')`**, the `CURRENT_TIMESTAMP` space form
|
|
279
|
+
* (`'2026-09-03 21:33:07'`). That form is wrong for this codebase in two ways that both
|
|
280
|
+
* fail silently:
|
|
281
|
+
*
|
|
282
|
+
* - It does not compare against `$now()`, which is an ISO string. Lexicographic TEXT
|
|
283
|
+
* comparison is exact within one format and wrong across two: both open with
|
|
284
|
+
* `YYYY-MM-DD`, so values on different dates still order correctly, but on the SAME
|
|
285
|
+
* date index 10 decides — a space (0x20) always sorts below `T` (0x54), whatever the
|
|
286
|
+
* time-of-day. So `{ publishedAt: { lte: $now() } }` over a space-form column matches
|
|
287
|
+
* any row dated today, including one scheduled for later today. A time boundary that
|
|
288
|
+
* silently does not hold, for a day at a time.
|
|
289
|
+
* - It is second-resolution, so two writes in the same second are indistinguishable and
|
|
290
|
+
* `ORDER BY createdAt` falls back to an arbitrary tiebreak. `%f` gives milliseconds.
|
|
291
|
+
*
|
|
292
|
+
* Changing it means a column's stored values change shape, so **existing rows must be
|
|
293
|
+
* rewritten** — a rebuild copies values through untouched and would leave the two formats
|
|
294
|
+
* mixed in one column, which is worse than either alone. {@link isoTimestampBackfill} is
|
|
295
|
+
* that rewrite; declare it in `app.migrations`.
|
|
296
|
+
*
|
|
297
|
+
* `raw(sql)` remains the escape hatch for any other SQLite default expression, including
|
|
298
|
+
* `expr.raw("datetime('now')")` if you deliberately want the old shape.
|
|
299
|
+
*/
|
|
263
300
|
export const expr = {
|
|
264
|
-
now: (): ExprDefault => new ExprDefault(
|
|
301
|
+
now: (): ExprDefault => new ExprDefault(ISO_NOW_SQL),
|
|
265
302
|
raw: (sql: string): ExprDefault => new ExprDefault(sql),
|
|
266
303
|
};
|
|
267
304
|
|
package/src/worker.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
|
9
9
|
import type { EnvBag } from "./sdk/handlers";
|
|
10
10
|
import type { JsonValue } from "./sdk/infer";
|
|
11
11
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
12
|
+
import { appliedMigrations, runDataMigrations } from "./runtime/data-migrations";
|
|
12
13
|
import { createMail } from "./runtime/mail";
|
|
13
14
|
import { createQueue, type QueueProducerBinding } from "./runtime/queue";
|
|
14
15
|
import { dispatchQueueBatch, type QueueBatch, type QueueContext } from "./runtime/queue-consumer";
|
|
@@ -234,6 +235,43 @@ export function makeWorker(app: PramenApp) {
|
|
|
234
235
|
}
|
|
235
236
|
};
|
|
236
237
|
|
|
238
|
+
// The mirror of the DO's runDataMigrations() for the D1 store, run once per isolate after
|
|
239
|
+
// migration. It diverges in one deliberate way: it runs EVERY declared migration whatever
|
|
240
|
+
// partition it names, because D1 is ONE shared database with no partition split — every
|
|
241
|
+
// entity's table lives in it, so a migration declared for "audit" has real rows to touch
|
|
242
|
+
// here and would otherwise be permanently unrunnable on this store. Each is still recorded
|
|
243
|
+
// under its OWN declared partition key, so a ledger read is comparable across stores.
|
|
244
|
+
//
|
|
245
|
+
// Errors are NOT swallowed (unlike runBootstrapD1): the .catch in ensureD1Migrated clears
|
|
246
|
+
// `d1Ready`, so a failed migration fails this request and is retried on the next one —
|
|
247
|
+
// the same fail-closed contract as the DO path.
|
|
248
|
+
//
|
|
249
|
+
// ATOMICITY CAVEAT: D1's `transaction(fn)` is `fn()` (no interactive transactions), so
|
|
250
|
+
// here the claim and the work do NOT commit together. The runner claims the ledger row
|
|
251
|
+
// before running (which is what keeps two cold isolates from both applying the same
|
|
252
|
+
// backfill — `d1Ready` is per-isolate and there is no single writer) and releases it on a
|
|
253
|
+
// throw, so a failed migration leaves partial writes and re-runs. Write SQL that tolerates
|
|
254
|
+
// that (`WHERE col IS NULL`) when the D1 store is in play.
|
|
255
|
+
const runDataMigrationsD1 = async (driver: Driver): Promise<void> => {
|
|
256
|
+
const migrations = app.migrations;
|
|
257
|
+
if (!migrations?.length) return;
|
|
258
|
+
const db = new Db(driver, { acl: d1Acl, identity: { roles: ["admin"] }, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
259
|
+
await runDataMigrations(driver, migrations, {
|
|
260
|
+
// No `partition` — see the "runs every declared migration" note above.
|
|
261
|
+
makeContext: (partition) => ({ db, driver, schema: app.schema, partition }),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Partitions an admin route may address: the schema's, the default (always addressable),
|
|
266
|
+
* and any a data migration declares. Computed once — the app is static. */
|
|
267
|
+
let knownPartitionsCache: Set<string> | undefined;
|
|
268
|
+
const knownPartitions = (): Set<string> => {
|
|
269
|
+
if (!knownPartitionsCache) {
|
|
270
|
+
knownPartitionsCache = new Set([DEFAULT_PARTITION, ...partitionsOf(app.schema), ...(app.migrations ?? []).map((m) => m.partition ?? DEFAULT_PARTITION)]);
|
|
271
|
+
}
|
|
272
|
+
return knownPartitionsCache;
|
|
273
|
+
};
|
|
274
|
+
|
|
237
275
|
let d1Ready: Promise<void> | undefined;
|
|
238
276
|
/** Run one handler against the D1 store, in the Worker. The request path and the
|
|
239
277
|
* PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
|
|
@@ -293,6 +331,7 @@ export function makeWorker(app: PramenApp) {
|
|
|
293
331
|
const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
|
|
294
332
|
if (!d1Ready) {
|
|
295
333
|
d1Ready = migrate(driver, app.schema, { allowDestructive })
|
|
334
|
+
.then(() => runDataMigrationsD1(driver)) // imperative, recorded backfills (fail closed)
|
|
296
335
|
.then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
|
|
297
336
|
.then(() => runBootstrapD1(driver)) // converge code-defined reference data
|
|
298
337
|
.then(() => undefined)
|
|
@@ -354,6 +393,26 @@ export function makeWorker(app: PramenApp) {
|
|
|
354
393
|
return listTasks(driver, { status, limit });
|
|
355
394
|
};
|
|
356
395
|
|
|
396
|
+
/** Read the data-migration ledger straight from D1 in the Worker (there is no DO on this
|
|
397
|
+
* path), shaped exactly like the DO's /__migrations answer so the CLI can't tell them
|
|
398
|
+
* apart. The D1 ledger holds every partition's rows; filter to the one asked for.
|
|
399
|
+
*
|
|
400
|
+
* Strictly READ-ONLY: deliberately NOT ensureD1Migrated / ensureMigrationsTable. Booting
|
|
401
|
+
* the store here would apply every pending backfill as a side effect of asking about them,
|
|
402
|
+
* so `migrations status` could never report PENDING — which is the only question the
|
|
403
|
+
* command exists to answer. An absent ledger table is simply "none applied". */
|
|
404
|
+
const listD1Migrations = async (env: Env, tenant: string, partition: string): Promise<unknown> => {
|
|
405
|
+
if (!env.DB) throw new BadRequest("D1 store is not configured");
|
|
406
|
+
// The same COMMINGLING GUARD as every other D1 entry point: one shared database with no
|
|
407
|
+
// tenant column, so reporting it as some specific tenant's ledger requires the opt-in.
|
|
408
|
+
if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
|
|
409
|
+
throw new Forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
|
|
410
|
+
}
|
|
411
|
+
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
412
|
+
const rows = await appliedMigrations(driver, partition).catch(() => []);
|
|
413
|
+
return { partition, applied: rows.map((r) => ({ id: r.id, appliedAt: r.appliedAt })) };
|
|
414
|
+
};
|
|
415
|
+
|
|
357
416
|
return {
|
|
358
417
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
359
418
|
const url = new URL(request.url);
|
|
@@ -482,6 +541,35 @@ export function makeWorker(app: PramenApp) {
|
|
|
482
541
|
return withCors(res, cors);
|
|
483
542
|
}
|
|
484
543
|
|
|
544
|
+
// --- admin: which data migrations a tenant has applied. The fleet-wide "is it safe to
|
|
545
|
+
// prune this id?" signal — a cold tenant is unmigrated until touched, so nothing else
|
|
546
|
+
// can answer it. `x-pramen-store: d1` reads the Worker's shared ledger instead. ---
|
|
547
|
+
if (url.pathname === "/admin/migrations") {
|
|
548
|
+
if (!isAdmin(identity)) return withCors(forbidden("migrations"), cors);
|
|
549
|
+
const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
|
|
550
|
+
const tenant = url.searchParams.get("tenant") ?? "main";
|
|
551
|
+
// `partition` is caller-supplied and reaches partitionStubFor, which INSTANTIATES a
|
|
552
|
+
// DO — the same reason /live validates it: without this an admin typo mints a junk DO
|
|
553
|
+
// and a permanent registry key for a partition nothing lives in. A migration may
|
|
554
|
+
// declare a partition of its own, so accept those too.
|
|
555
|
+
if (!knownPartitions().has(partition)) return withCors(badRequest(`unknown partition '${partition}'`), cors);
|
|
556
|
+
try {
|
|
557
|
+
if (request.headers.get("x-pramen-store") === "d1") {
|
|
558
|
+
return withCors(json({ ok: true, result: await listD1Migrations(env, tenant, partition) }), cors);
|
|
559
|
+
}
|
|
560
|
+
const stub = partitionStubFor(env, tenant, partition);
|
|
561
|
+
const res = await stub.fetch(
|
|
562
|
+
new Request("https://do/__migrations", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }),
|
|
563
|
+
);
|
|
564
|
+
return withCors(res, cors);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
// The DO branch throws a plain Error on a D1-ONLY deployment (no PRAMEN binding).
|
|
567
|
+
// Uncaught that is an opaque 500; surfaced, it's the actionable "pin the D1 store".
|
|
568
|
+
const { status, body } = toResponse(err);
|
|
569
|
+
return withCors(json(body, status), cors);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
485
573
|
// --- admin: generic data ops over a tenant's tables (browse/edit any row).
|
|
486
574
|
// Body: { tenant, table, op: list|get|create|update|delete|count, ... }. Runs
|
|
487
575
|
// in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
|
|
@@ -564,7 +652,7 @@ export function makeWorker(app: PramenApp) {
|
|
|
564
652
|
"Header X-Pramen-Tenant selects the store (default: main). " +
|
|
565
653
|
"Admin (optional partition selects the partition DO, default: " + DEFAULT_PARTITION + "): " +
|
|
566
654
|
"GET /tenants, POST /admin/recover {tenant,timestamp,partition?}, GET /admin/schema?tenant=&partition=, " +
|
|
567
|
-
"POST /admin/data {tenant,table,op,partition?}.\n",
|
|
655
|
+
"GET /admin/migrations?tenant=&partition=, POST /admin/data {tenant,table,op,partition?}.\n",
|
|
568
656
|
{ headers: { "content-type": "text/plain" } },
|
|
569
657
|
);
|
|
570
658
|
}
|