@pramen/server 0.0.22 → 0.0.24

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.
@@ -45,6 +45,7 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
45
45
  private readonly subsBySocket;
46
46
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
47
47
  private ensureMigrated;
48
+ private runBootstrap;
48
49
  fetch(request: Request): Promise<Response>;
49
50
  /** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
50
51
  * pending alarm; a near-future time batches a burst of enqueues into one drain. */
@@ -95,9 +95,32 @@ export class PramenDOBase extends DurableObject {
95
95
  return; // a concurrent first request already migrated
96
96
  await this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => { }));
97
97
  await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
98
+ await this.runBootstrap(); // converge code-defined reference data (default partition only)
98
99
  this.migrated = true;
99
100
  });
100
101
  }
102
+ // Run app.bootstrap() once per DO lifetime, right after migration and inside the same
103
+ // blockConcurrencyWhile block, so the first request sees a converged store and two
104
+ // concurrent first fetches can't double-run it. Default partition ONLY: reference data
105
+ // (content types, roles, …) lives in the default partition, and a non-default DO doesn't
106
+ // own those tables (a write would trip assertInPartition). A failing reconciler is logged
107
+ // and swallowed — unlike migrate(), it must never brick a tenant's boot; it retries next
108
+ // boot. Uses a SYSTEM-scoped Db (ACL bypassed) with triggers suppressed (a boot-time seed
109
+ // shouldn't fan out reactive side-effects).
110
+ async runBootstrap() {
111
+ const fns = this.app.bootstrap;
112
+ if (!fns?.length || this.partition !== DEFAULT_PARTITION)
113
+ return;
114
+ const db = new Db(this.driver, { acl: this.acl, identity: { roles: ["admin"] }, system: true, partition: this.partition, schema: this.app.schema, suppressTriggers: true }, this.app.schema);
115
+ for (const fn of fns) {
116
+ try {
117
+ await this.driver.transaction(() => Promise.resolve(fn({ db, driver: this.driver, schema: this.app.schema, partition: this.partition })));
118
+ }
119
+ catch (e) {
120
+ console.error(`[pramen] bootstrap failed (partition=${this.partition}):`, e);
121
+ }
122
+ }
123
+ }
101
124
  async fetch(request) {
102
125
  const tenantHeader = request.headers.get("x-pramen-tenant");
103
126
  if (tenantHeader)
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ 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
6
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
7
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
7
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } 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";
package/dist/pramen.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Env } from "./worker";
2
2
  import { pramenDO, type DoEnv } from "./durable-object";
3
3
  import { type SchemaDef } from "./sdk/schema";
4
- import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
4
+ import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
5
5
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
6
6
  import type { Role } from "./sdk/acl";
7
7
  /** Injected into a public route's handler — forward a privileged mutation into the
@@ -41,6 +41,10 @@ export interface PramenApp {
41
41
  * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
42
42
  * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
43
43
  queues?: AppQueueMap;
44
+ /** Idempotent reconcilers run once after schema migration on each boot — converge
45
+ * code-defined reference data into the store (see `BootstrapFn`). Each runs with a
46
+ * privileged system Db; failures are logged, never fatal. */
47
+ bootstrap?: readonly BootstrapFn[];
44
48
  }
45
49
  export type { Env, DoEnv };
46
50
  /** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
@@ -1,4 +1,5 @@
1
1
  import type { Db } from "../runtime/db";
2
+ import type { Driver } from "../runtime/driver";
2
3
  import type { Kv } from "../runtime/kv";
3
4
  import type { Mail } from "../runtime/mail";
4
5
  import type { Queue } from "../runtime/queue";
@@ -39,6 +40,26 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
39
40
  * Declare queues in oblaka.ts; consume them via `app.queues`. */
40
41
  readonly queue: Queue;
41
42
  }
43
+ /** Context handed to each `app.bootstrap` function. A privileged, SYSTEM-scoped `Db` (ACL
44
+ * bypassed) plus the raw driver, available once schema migration has run on boot. Use it to
45
+ * reconcile CODE-DEFINED reference data — content types, block types, roles, feature flags —
46
+ * into the store, so a fresh / reprovisioned database converges to what the repo declares
47
+ * instead of depending on rows someone created by hand. */
48
+ export interface BootstrapContext<S extends SchemaDef = SchemaDef> {
49
+ /** System-scoped Db (ACL bypassed), scoped to `partition`. */
50
+ readonly db: Db<S>;
51
+ /** Raw driver — for `driver.transaction(...)` or bespoke SQL. */
52
+ readonly driver: Driver;
53
+ readonly schema: S;
54
+ /** The partition being booted. On the DO path bootstrap runs ONLY for the default
55
+ * partition (reference data lives there); on the D1 path it is always the default. */
56
+ readonly partition: string;
57
+ }
58
+ /** An idempotent reconcile run once after `migrate()` on each boot (a DO's first fetch, or a
59
+ * Worker/D1 isolate init). It MUST be safe to run repeatedly — upsert by a stable key, never
60
+ * blind-insert. A thrown error is logged and swallowed so a broken reconcile can't brick a
61
+ * tenant's boot; it simply retries on the next boot. Set as `app.bootstrap`. */
62
+ export type BootstrapFn = (ctx: BootstrapContext) => void | Promise<void>;
42
63
  /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
43
64
  export interface Tasks {
44
65
  /** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;
package/dist/worker.js CHANGED
@@ -129,11 +129,30 @@ export function makeWorker(app) {
129
129
  // its own). Schema migration over D1 runs once per isolate (and short-circuits on a
130
130
  // stored schema hash thereafter); a failed run is not cached.
131
131
  const d1Acl = compileAcl(app.acl ?? []);
132
+ // Converge code-defined reference data on the D1 path — the mirror of the DO's
133
+ // runBootstrap(), run once per isolate after migration. D1 is a single shared store (no
134
+ // partition split), so every reconciler runs under the default partition. SYSTEM-scoped
135
+ // Db (ACL bypassed), triggers suppressed; a failing reconciler is logged, never fatal.
136
+ const runBootstrapD1 = async (driver) => {
137
+ const fns = app.bootstrap;
138
+ if (!fns?.length)
139
+ return;
140
+ const db = new Db(driver, { acl: d1Acl, identity: { roles: ["admin"] }, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
141
+ for (const fn of fns) {
142
+ try {
143
+ await driver.transaction(() => Promise.resolve(fn({ db, driver, schema: app.schema, partition: DEFAULT_PARTITION })));
144
+ }
145
+ catch (e) {
146
+ console.error("[pramen] bootstrap failed (d1):", e);
147
+ }
148
+ }
149
+ };
132
150
  let d1Ready;
133
151
  const ensureD1Migrated = (driver, allowDestructive) => {
134
152
  if (!d1Ready) {
135
153
  d1Ready = migrate(driver, app.schema, { allowDestructive })
136
154
  .then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
155
+ .then(() => runBootstrapD1(driver)) // converge code-defined reference data
137
156
  .then(() => undefined)
138
157
  .catch((e) => {
139
158
  d1Ready = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -133,10 +133,36 @@ export class PramenDOBase extends DurableObject<DoEnv> {
133
133
  migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => {}),
134
134
  );
135
135
  await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
136
+ await this.runBootstrap(); // converge code-defined reference data (default partition only)
136
137
  this.migrated = true;
137
138
  });
138
139
  }
139
140
 
141
+ // Run app.bootstrap() once per DO lifetime, right after migration and inside the same
142
+ // blockConcurrencyWhile block, so the first request sees a converged store and two
143
+ // concurrent first fetches can't double-run it. Default partition ONLY: reference data
144
+ // (content types, roles, …) lives in the default partition, and a non-default DO doesn't
145
+ // own those tables (a write would trip assertInPartition). A failing reconciler is logged
146
+ // and swallowed — unlike migrate(), it must never brick a tenant's boot; it retries next
147
+ // boot. Uses a SYSTEM-scoped Db (ACL bypassed) with triggers suppressed (a boot-time seed
148
+ // shouldn't fan out reactive side-effects).
149
+ private async runBootstrap(): Promise<void> {
150
+ const fns = this.app.bootstrap;
151
+ if (!fns?.length || this.partition !== DEFAULT_PARTITION) return;
152
+ const db = new Db(
153
+ this.driver,
154
+ { acl: this.acl, identity: { roles: ["admin"] }, system: true, partition: this.partition, schema: this.app.schema, suppressTriggers: true },
155
+ this.app.schema,
156
+ );
157
+ for (const fn of fns) {
158
+ try {
159
+ await this.driver.transaction(() => Promise.resolve(fn({ db, driver: this.driver, schema: this.app.schema, partition: this.partition })));
160
+ } catch (e) {
161
+ console.error(`[pramen] bootstrap failed (partition=${this.partition}):`, e);
162
+ }
163
+ }
164
+ }
165
+
140
166
  override async fetch(request: Request): Promise<Response> {
141
167
  const tenantHeader = request.headers.get("x-pramen-tenant");
142
168
  if (tenantHeader) this.tenant = tenantHeader;
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  // --- app + handlers ---
28
28
  export { createApp } from "./sdk/app";
29
29
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
30
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
30
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
31
31
 
32
32
  // --- ACL ---
33
33
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
package/src/pramen.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { makeWorker, type Env } from "./worker";
16
16
  import { pramenDO, type DoEnv } from "./durable-object";
17
17
  import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
- import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
18
+ import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
19
19
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
20
20
  import type { Role } from "./sdk/acl";
21
21
 
@@ -53,6 +53,10 @@ export interface PramenApp {
53
53
  * `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
54
54
  * Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
55
55
  queues?: AppQueueMap;
56
+ /** Idempotent reconcilers run once after schema migration on each boot — converge
57
+ * code-defined reference data into the store (see `BootstrapFn`). Each runs with a
58
+ * privileged system Db; failures are logged, never fatal. */
59
+ bootstrap?: readonly BootstrapFn[];
56
60
  }
57
61
 
58
62
  export type { Env, DoEnv };
@@ -3,6 +3,7 @@
3
3
  // rolls back on throw (see runtime/dispatch.ts).
4
4
 
5
5
  import type { Db } from "../runtime/db";
6
+ import type { Driver } from "../runtime/driver";
6
7
  import type { Kv } from "../runtime/kv";
7
8
  import type { Mail } from "../runtime/mail";
8
9
  import type { Queue } from "../runtime/queue";
@@ -45,6 +46,28 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
45
46
  readonly queue: Queue;
46
47
  }
47
48
 
49
+ /** Context handed to each `app.bootstrap` function. A privileged, SYSTEM-scoped `Db` (ACL
50
+ * bypassed) plus the raw driver, available once schema migration has run on boot. Use it to
51
+ * reconcile CODE-DEFINED reference data — content types, block types, roles, feature flags —
52
+ * into the store, so a fresh / reprovisioned database converges to what the repo declares
53
+ * instead of depending on rows someone created by hand. */
54
+ export interface BootstrapContext<S extends SchemaDef = SchemaDef> {
55
+ /** System-scoped Db (ACL bypassed), scoped to `partition`. */
56
+ readonly db: Db<S>;
57
+ /** Raw driver — for `driver.transaction(...)` or bespoke SQL. */
58
+ readonly driver: Driver;
59
+ readonly schema: S;
60
+ /** The partition being booted. On the DO path bootstrap runs ONLY for the default
61
+ * partition (reference data lives there); on the D1 path it is always the default. */
62
+ readonly partition: string;
63
+ }
64
+
65
+ /** An idempotent reconcile run once after `migrate()` on each boot (a DO's first fetch, or a
66
+ * Worker/D1 isolate init). It MUST be safe to run repeatedly — upsert by a stable key, never
67
+ * blind-insert. A thrown error is logged and swallowed so a broken reconcile can't brick a
68
+ * tenant's boot; it simply retries on the next boot. Set as `app.bootstrap`. */
69
+ export type BootstrapFn = (ctx: BootstrapContext) => void | Promise<void>;
70
+
48
71
  /** The deferred-side-effects facade handed to handlers as `ctx.tasks`. */
49
72
  export interface Tasks {
50
73
  /** Enqueue a task to run after commit. `kind` selects the `app.tasks` handler;
package/src/worker.ts CHANGED
@@ -191,11 +191,30 @@ export function makeWorker(app: PramenApp) {
191
191
  // its own). Schema migration over D1 runs once per isolate (and short-circuits on a
192
192
  // stored schema hash thereafter); a failed run is not cached.
193
193
  const d1Acl = compileAcl(app.acl ?? []);
194
+
195
+ // Converge code-defined reference data on the D1 path — the mirror of the DO's
196
+ // runBootstrap(), run once per isolate after migration. D1 is a single shared store (no
197
+ // partition split), so every reconciler runs under the default partition. SYSTEM-scoped
198
+ // Db (ACL bypassed), triggers suppressed; a failing reconciler is logged, never fatal.
199
+ const runBootstrapD1 = async (driver: Driver): Promise<void> => {
200
+ const fns = app.bootstrap;
201
+ if (!fns?.length) return;
202
+ const db = new Db(driver, { acl: d1Acl, identity: { roles: ["admin"] }, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
203
+ for (const fn of fns) {
204
+ try {
205
+ await driver.transaction(() => Promise.resolve(fn({ db, driver, schema: app.schema, partition: DEFAULT_PARTITION })));
206
+ } catch (e) {
207
+ console.error("[pramen] bootstrap failed (d1):", e);
208
+ }
209
+ }
210
+ };
211
+
194
212
  let d1Ready: Promise<void> | undefined;
195
213
  const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
196
214
  if (!d1Ready) {
197
215
  d1Ready = migrate(driver, app.schema, { allowDestructive })
198
216
  .then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
217
+ .then(() => runBootstrapD1(driver)) // converge code-defined reference data
199
218
  .then(() => undefined)
200
219
  .catch((e) => {
201
220
  d1Ready = undefined;