@oimlsmart/platform-server 0.1.9 → 0.2.0

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/README.md CHANGED
@@ -11,8 +11,14 @@ pin IS the contract (TODO.repos/01).
11
11
  ## What it is
12
12
 
13
13
  - `store`, the ServerStore seam: the interface, every row type, the
14
- demo-account plan, `installStore`/`getStore`. Worker-safe.
15
- - `store/d1`, the Cloudflare D1 implementation. Worker-safe.
14
+ demo-account plan, `installStore`/`getStore`, and `StoreUnavailable`
15
+ (the bounded writes' honest timeout error). Worker-safe.
16
+ - `store/d1`, the Cloudflare D1 implementation: every write/batch/DDL
17
+ statement races a confirmation budget (`DEFAULT_STORE_WRITE_BUDGET_MS`
18
+ = 5 s; the consumer's `STORE_WRITE_BUDGET_MS` env binding retunes it
19
+ through `resolveStoreWriteBudgetMs` + the `d1StoreFor`/`D1ServerStore`
20
+ options) — a timeout throws the seam's `StoreUnavailable`. Reads stay
21
+ unbounded. Worker-safe.
16
22
  - `store/sqlite`, the node implementation (better-sqlite3): the server
17
23
  store composed from the sync modules, the entity-store verbs, the raw
18
24
  driver handle, the schema (`SQLITE_SCHEMA_PATH`), and the migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/store/d1.ts CHANGED
@@ -16,11 +16,22 @@
16
16
  // d1-store.test.ts runs this class against a better-sqlite3-backed D1
17
17
  // facade (the binding contract with real semantics) plus the real
18
18
  // workerd binding through scripts/cloudflare-smoke.ts there.
19
+ //
20
+ // THE BOUNDED-WRITE DISCIPLINE (the 2026-09-01 outage's lesson): every
21
+ // write/batch/DDL statement races a confirmation budget (the default
22
+ // DEFAULT_STORE_WRITE_BUDGET_MS, tunable per deployment through the
23
+ // consumer's STORE_WRITE_BUDGET_MS env binding) — a slow/hung store
24
+ // write answers StoreUnavailable in seconds, never spins the request
25
+ // forever. READS stay unbounded here: the read-path latency story is
26
+ // read replication, not this bound. The constructor wraps the binding
27
+ // in the bounded facade ONCE (boundedD1Writes below); the ensure memos
28
+ // key on the RAW binding (a rejected chain still evicts itself).
19
29
  // ═══════════════════════════════════════════════════════════════════
20
30
 
21
31
  import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
22
32
  import {
23
33
  DEMO_PASSWORD,
34
+ StoreUnavailable,
24
35
  type AccountEmail,
25
36
  type AddAccountEmailResult,
26
37
  type AdvanceCounterResult,
@@ -255,8 +266,159 @@ const lastSeenWrites = new Map<string, number>()
255
266
  const LAST_SEEN_THROTTLE_MS = 60_000
256
267
  const LAST_SEEN_CACHE_CAP = 4096
257
268
 
269
+ // ── The bounded-write discipline (the 2026-09-01 outage's lesson) ────
270
+ // Yesterday's outage: a cross-region path flap hung the identity
271
+ // service's D1 WRITES indefinitely and the login page spun for minutes.
272
+ // The discipline: every write/batch/DDL statement races a confirmation
273
+ // budget and a timeout answers StoreUnavailable — an honest error in
274
+ // seconds, never an infinite spin. READS (SELECT/PRAGMA/…) pass through
275
+ // untouched: the read-path latency story is read replication, a
276
+ // separate fix. The bound wraps the BINDING once at construction (the
277
+ // store's every write path — the 180+ .run() sites, the batches, the
278
+ // ensure chains' DDL — flows through it), so no call site changes.
279
+
280
+ /** The default write-confirmation budget: D1's healthy write latency is
281
+ * tens of ms; 5 s is two orders of magnitude past any honest p99 yet
282
+ * still "seconds" to a waiting human — the 503 reaches the login page
283
+ * before the browser's own patience ends. Tunable per deployment (the
284
+ * consumer's STORE_WRITE_BUDGET_MS env binding). */
285
+ export const DEFAULT_STORE_WRITE_BUDGET_MS = 5_000
286
+
287
+ export interface D1WriteBudgetOptions {
288
+ /** The write/batch/DDL confirmation budget in ms. Absent:
289
+ * DEFAULT_STORE_WRITE_BUDGET_MS. */
290
+ writeBudgetMs?: number
291
+ }
292
+
293
+ /** The consumer-side env resolution (the resolveInstanceProfileFromEnv
294
+ * posture — the kernel never reads process.env; the Worker-safe
295
+ * modules take the env binding as an argument). Unset/garbage resolves
296
+ * undefined — the store's default stands, never a silent 0. */
297
+ export function resolveStoreWriteBudgetMs(env?: { STORE_WRITE_BUDGET_MS?: string }): number | undefined {
298
+ const raw = env?.STORE_WRITE_BUDGET_MS?.trim()
299
+ if (!raw) return undefined
300
+ const parsed = Number.parseInt(raw, 10)
301
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
302
+ }
303
+
304
+ /** The write-classified leading verbs (DDL included). Everything else —
305
+ * SELECT, the PRAGMA probes, EXPLAIN — is a read and stays unbounded. */
306
+ const WRITE_STATEMENT = /^\s*(?:insert|update|delete|replace|create|alter|drop|vacuum|reindex)\b/i
307
+
308
+ /** The statement's idempotency posture for StoreUnavailable.retrySafe:
309
+ * the shapes whose replay converges after a write that may have
310
+ * landed — keyed UPDATE/DELETE/REPLACE, INSERT OR IGNORE/REPLACE, the
311
+ * ON CONFLICT upserts, the IF NOT EXISTS heals. A plain INSERT is NOT
312
+ * claimed safe (a retry could double-land). */
313
+ function writeRetrySafe(sql: string): boolean {
314
+ if (/^\s*(?:update|delete|replace)\b/i.test(sql)) return true
315
+ if (/^\s*insert\s+or\s+(?:ignore|replace)\b/i.test(sql)) return true
316
+ if (/^\s*insert\b/i.test(sql) && /\bon\s+conflict\b/i.test(sql)) return true
317
+ if (/^\s*create\b/i.test(sql) && /\bif\s+not\s+exists\b/i.test(sql)) return true
318
+ return false
319
+ }
320
+
321
+ /** The error/log label for a write: the verb phrase + the target object
322
+ * ('UPDATE sessions', 'CREATE TABLE IF NOT EXISTS org_memberships').
323
+ * Never the bound values — the label is all an error may carry. */
324
+ function sqlWriteLabel(sql: string): string {
325
+ const flat = sql.trim().replace(/\s+/g, ' ')
326
+ const m = /^((?:insert(?:\s+or\s+(?:ignore|replace))?\s+into|update|delete\s+from|replace\s+into|create\s+(?:unique\s+)?(?:table|index)(?:\s+if\s+not\s+exists)?|alter\s+table|drop\s+(?:table|index)(?:\s+if\s+exists)?))\s+("?\w+"?)/i.exec(flat)
327
+ if (m) return `${m[1].toUpperCase()} ${m[2]}`
328
+ return flat.slice(0, 60)
329
+ }
330
+
331
+ /** Race a write against its confirmation budget. The in-flight write is
332
+ * never cancelled (D1 has no abort) — the race's handlers stay attached
333
+ * past the timeout, so a late settling (success OR failure) never
334
+ * surfaces as an unhandled rejection. */
335
+ function raceWriteBudget<T>(operation: string, budgetMs: number, retrySafe: boolean, pending: Promise<T>): Promise<T> {
336
+ return new Promise<T>((resolve, reject) => {
337
+ const timer = setTimeout(() => reject(new StoreUnavailable(operation, budgetMs, retrySafe)), budgetMs)
338
+ pending.then(
339
+ value => { clearTimeout(timer); resolve(value) },
340
+ (error: unknown) => { clearTimeout(timer); reject(error) },
341
+ )
342
+ })
343
+ }
344
+
345
+ /** The wrapper → the underlying statement (+ its idempotency posture).
346
+ * batch() hands the REAL statements back to the binding: the runtime
347
+ * reads the SQL off the statement object itself, so a wrapper is not a
348
+ * statement to it. WeakMap-keyed — the entries die with the wrappers. */
349
+ const unwrappedStatements = new WeakMap<D1PreparedStatement, { stmt: D1PreparedStatement; retrySafe: boolean }>()
350
+
351
+ /** The write-bounded statement facade: read-classified SQL returns the
352
+ * raw statement untouched; write-classified SQL gets every terminal
353
+ * (run/first/all/raw — INSERT … RETURNING reads back through a write)
354
+ * raced against the budget. bind() stays chainable on the wrapper. */
355
+ function boundedStatement(stmt: D1PreparedStatement, sql: string, budgetMs: number): D1PreparedStatement {
356
+ if (!WRITE_STATEMENT.test(sql)) return stmt
357
+ const operation = sqlWriteLabel(sql)
358
+ const retrySafe = writeRetrySafe(sql)
359
+ const wrap = (s: D1PreparedStatement): D1PreparedStatement => {
360
+ const wrapped = {
361
+ bind: (...values: unknown[]): D1PreparedStatement => wrap(s.bind(...values)),
362
+ first: (<T = unknown>(colName?: string) =>
363
+ raceWriteBudget(operation, budgetMs, retrySafe, (colName === undefined ? s.first<T>() : s.first<T>(colName)) as Promise<T | null>)
364
+ ) as D1PreparedStatement['first'],
365
+ run: (<T = Record<string, unknown>>() =>
366
+ raceWriteBudget(operation, budgetMs, retrySafe, s.run<T>())
367
+ ) as D1PreparedStatement['run'],
368
+ all: (<T = Record<string, unknown>>() =>
369
+ raceWriteBudget(operation, budgetMs, retrySafe, s.all<T>())
370
+ ) as D1PreparedStatement['all'],
371
+ raw: (<T = unknown[]>(options?: { columnNames?: boolean }) =>
372
+ options?.columnNames
373
+ ? raceWriteBudget(operation, budgetMs, retrySafe, s.raw<T>({ columnNames: true }))
374
+ : raceWriteBudget(operation, budgetMs, retrySafe, s.raw<T>(options as { columnNames?: false } | undefined))
375
+ ) as D1PreparedStatement['raw'],
376
+ }
377
+ unwrappedStatements.set(wrapped as D1PreparedStatement, { stmt: s, retrySafe })
378
+ return wrapped as D1PreparedStatement
379
+ }
380
+ return wrap(stmt)
381
+ }
382
+
383
+ /** The binding facade the store holds: prepare() bounds write-classified
384
+ * statements, batch()/exec() race their own budget (a D1 batch is ONE
385
+ * round-trip — one budget, retry-safe exactly when every member is),
386
+ * dump()/the reads pass through, withSession()'s session gets the same
387
+ * discipline. One budget per round-trip, never per row. */
388
+ function boundedD1Writes(binding: D1Database, budgetMs: number): D1Database {
389
+ const boundedBatch = <T>(run: (real: D1PreparedStatement[]) => Promise<T>, statements: D1PreparedStatement[],): Promise<T> => {
390
+ const real = statements.map(s => unwrappedStatements.get(s)?.stmt ?? s)
391
+ const retrySafe = statements.length > 0 && statements.every(s => unwrappedStatements.get(s)?.retrySafe === true)
392
+ return raceWriteBudget(`batch (${statements.length} statements)`, budgetMs, retrySafe, run(real))
393
+ }
394
+ return {
395
+ prepare: (sql: string) => boundedStatement(binding.prepare(sql), sql, budgetMs),
396
+ batch: <T = unknown>(statements: D1PreparedStatement[]) => boundedBatch(r => binding.batch<T>(r), statements),
397
+ exec: (sql: string) => raceWriteBudget('exec (DDL script)', budgetMs, false, binding.exec(sql)),
398
+ dump: () => binding.dump(),
399
+ withSession: (constraintOrBookmark?: string) => {
400
+ const session = binding.withSession(constraintOrBookmark)
401
+ return {
402
+ prepare: (sql: string) => boundedStatement(session.prepare(sql), sql, budgetMs),
403
+ batch: <T = unknown>(statements: D1PreparedStatement[]) => boundedBatch(r => session.batch<T>(r), statements),
404
+ getBookmark: () => session.getBookmark(),
405
+ }
406
+ },
407
+ }
408
+ }
409
+
258
410
  export class D1ServerStore implements ServerStore {
259
- constructor(private readonly db: D1Database) {}
411
+ /** The RAW binding — the ensure memos (and d1StoreFor's map) key on
412
+ * it: the facade below is per-instance and would never hit. */
413
+ private readonly binding: D1Database
414
+ /** The bounded-write facade over the binding (the discipline note
415
+ * above) — every statement/batch the store issues flows through it. */
416
+ private readonly db: D1Database
417
+
418
+ constructor(binding: D1Database, opts?: D1WriteBudgetOptions) {
419
+ this.binding = binding
420
+ this.db = boundedD1Writes(binding, opts?.writeBudgetMs ?? DEFAULT_STORE_WRITE_BUDGET_MS)
421
+ }
260
422
 
261
423
  private stmt(sql: string, ...params: unknown[]): D1PreparedStatement {
262
424
  return this.db.prepare(sql).bind(...params)
@@ -267,7 +429,7 @@ export class D1ServerStore implements ServerStore {
267
429
  // user methods ensure them defensively (PRAGMA probe + ALTER),
268
430
  // memoized per (binding, chain) at module scope (the header note).
269
431
  private ensureUserColumns(): Promise<void> {
270
- return ensured(this.db, 'usersColumns', async () => {
432
+ return ensured(this.binding, 'usersColumns', async () => {
271
433
  const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
272
434
  const names = new Set(cols.results.map(c => c.name))
273
435
  if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
@@ -284,7 +446,7 @@ export class D1ServerStore implements ServerStore {
284
446
  // them defensively (the ensureUserColumns posture, memoized per
285
447
  // (binding, chain) at module scope).
286
448
  private ensureSessionColumns(): Promise<void> {
287
- return ensured(this.db, 'sessionColumns', async () => {
449
+ return ensured(this.binding, 'sessionColumns', async () => {
288
450
  const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
289
451
  const names = new Set(cols.results.map(c => c.name))
290
452
  if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
@@ -304,7 +466,7 @@ export class D1ServerStore implements ServerStore {
304
466
  // migration's twin) is IDEMPOTENT and rides the ensure: every
305
467
  // org-bound account's primary membership mirrors the legacy columns.
306
468
  private ensureMembershipSupport(): Promise<void> {
307
- return ensured(this.db, 'membershipSupport', async () => {
469
+ return ensured(this.binding, 'membershipSupport', async () => {
308
470
  await this.db.prepare(
309
471
  `CREATE TABLE IF NOT EXISTS org_memberships (
310
472
  id TEXT PRIMARY KEY,
@@ -359,7 +521,7 @@ export class D1ServerStore implements ServerStore {
359
521
  // defensively (the ensureMembershipSupport posture, memoized per
360
522
  // (binding, chain) at module scope).
361
523
  private ensureOrgRegistrySupport(): Promise<void> {
362
- return ensured(this.db, 'orgRegistrySupport', async () => {
524
+ return ensured(this.binding, 'orgRegistrySupport', async () => {
363
525
  await this.db.prepare(
364
526
  `CREATE TABLE IF NOT EXISTS org_registry (
365
527
  id TEXT PRIMARY KEY,
@@ -399,7 +561,7 @@ export class D1ServerStore implements ServerStore {
399
561
  // ensureOrgRegistrySupport posture, memoized per (binding, chain) at
400
562
  // module scope).
401
563
  private ensureHolderAttributionSupport(): Promise<void> {
402
- return ensured(this.db, 'holderAttributionSupport', async () => {
564
+ return ensured(this.binding, 'holderAttributionSupport', async () => {
403
565
  await this.db.prepare(
404
566
  `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
405
567
  certificate_id TEXT PRIMARY KEY,
@@ -439,7 +601,7 @@ export class D1ServerStore implements ServerStore {
439
601
  // ensure it defensively (the ensureOrgRegistrySupport posture,
440
602
  // memoized per (binding, chain) at module scope).
441
603
  private ensureInstrumentRegistrationSupport(): Promise<void> {
442
- return ensured(this.db, 'instrumentRegistrationSupport', async () => {
604
+ return ensured(this.binding, 'instrumentRegistrationSupport', async () => {
443
605
  await this.db.prepare(
444
606
  `CREATE TABLE IF NOT EXISTS instrument_registrations (
445
607
  id TEXT PRIMARY KEY,
@@ -471,7 +633,7 @@ export class D1ServerStore implements ServerStore {
471
633
  // per (binding, chain) at module scope), so a dev D1 migrated from
472
634
  // before the wave never 500s the core flow.
473
635
  private ensureOidcColumns(): Promise<void> {
474
- return ensured(this.db, 'oidcColumns', async () => {
636
+ return ensured(this.binding, 'oidcColumns', async () => {
475
637
  const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
476
638
  if (!codeCols.results.some(c => c.name === 'amr')) {
477
639
  await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
@@ -489,7 +651,7 @@ export class D1ServerStore implements ServerStore {
489
651
  // it defensively (the ensureOrgRegistrySupport posture, memoized per
490
652
  // (binding, chain) at module scope).
491
653
  private ensurePersonalAccessTokenSupport(): Promise<void> {
492
- return ensured(this.db, 'personalAccessTokenSupport', async () => {
654
+ return ensured(this.binding, 'personalAccessTokenSupport', async () => {
493
655
  await this.db.prepare(
494
656
  `CREATE TABLE IF NOT EXISTS personal_access_tokens (
495
657
  id TEXT PRIMARY KEY,
@@ -519,7 +681,7 @@ export class D1ServerStore implements ServerStore {
519
681
  // it defensively (the ensurePersonalAccessTokenSupport posture,
520
682
  // memoized per (binding, chain) at module scope).
521
683
  private ensureConsentGrantSupport(): Promise<void> {
522
- return ensured(this.db, 'consentGrantSupport', async () => {
684
+ return ensured(this.binding, 'consentGrantSupport', async () => {
523
685
  await this.db.prepare(
524
686
  `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
525
687
  id TEXT PRIMARY KEY,
@@ -544,7 +706,7 @@ export class D1ServerStore implements ServerStore {
544
706
  // ensureConsentGrantSupport posture, memoized per (binding, chain) at
545
707
  // module scope).
546
708
  private ensureAccountEmailSupport(): Promise<void> {
547
- return ensured(this.db, 'accountEmailSupport', async () => {
709
+ return ensured(this.binding, 'accountEmailSupport', async () => {
548
710
  await this.db.prepare(
549
711
  `CREATE TABLE IF NOT EXISTS account_emails (
550
712
  user_id TEXT NOT NULL REFERENCES users(id),
@@ -3373,7 +3535,7 @@ export class D1ServerStore implements ServerStore {
3373
3535
  // (Memoized per (binding, chain) at module scope — the header note.)
3374
3536
 
3375
3537
  private ensureNotifyDeliverySupport(): Promise<void> {
3376
- return ensured(this.db, 'notifyDeliverySupport', async () => {
3538
+ return ensured(this.binding, 'notifyDeliverySupport', async () => {
3377
3539
  await this.db.prepare(
3378
3540
  `CREATE TABLE IF NOT EXISTS notify_deliveries (
3379
3541
  id TEXT PRIMARY KEY,
@@ -3522,14 +3684,21 @@ export class D1ServerStore implements ServerStore {
3522
3684
 
3523
3685
  /** The worker entry's install: one store per binding, memoized (the
3524
3686
  * store is a stateless facade over the binding — safe to share across
3525
- * the isolate's concurrent requests). */
3687
+ * the isolate's concurrent requests). The write budget comes from the
3688
+ * FIRST resolution's opts: the memoized store keeps it (the
3689
+ * deployment's env is constant, so every request passes the same
3690
+ * value — a changed budget needs a fresh binding/isolate). */
3526
3691
  const byBinding = new WeakMap<D1Database, D1ServerStore>()
3527
3692
 
3528
- export function d1StoreFor(binding: D1Database): D1ServerStore {
3693
+ export function d1StoreFor(binding: D1Database, opts?: D1WriteBudgetOptions): D1ServerStore {
3529
3694
  let store = byBinding.get(binding)
3530
3695
  if (!store) {
3531
- store = new D1ServerStore(binding)
3696
+ store = new D1ServerStore(binding, opts)
3532
3697
  byBinding.set(binding, store)
3533
3698
  }
3534
3699
  return store
3535
3700
  }
3701
+
3702
+ // The seam's honest-unavailable error, re-exported where the consumer's
3703
+ // route surface already imports the D1 half from.
3704
+ export { StoreUnavailable }
package/src/store.ts CHANGED
@@ -1354,6 +1354,38 @@ export const DEMO_ACCOUNTS = [
1354
1354
 
1355
1355
  export const DEMO_PASSWORD = 'demo2026'
1356
1356
 
1357
+ /** The store's honest unavailable answer (the 2026-09-01 lesson — a
1358
+ * hung store write must answer in seconds, never spin the caller
1359
+ * forever): a bounded WRITE's confirmation did not arrive within its
1360
+ * budget. The route surface maps it to a 503 naming the store's
1361
+ * TEMPORARY unavailability + the retryability.
1362
+ *
1363
+ * The timed-out write may STILL land — the store accepted the
1364
+ * statement and it was the confirmation path that hung, so the error
1365
+ * never claims the write was lost. `retrySafe` names the statement's
1366
+ * own idempotency posture: true (an upsert, a keyed UPDATE/DELETE, an
1367
+ * IF NOT EXISTS heal) — a retry converges; false (a plain INSERT) —
1368
+ * reconcile before retrying. The reads are NOT bounded by this
1369
+ * discipline (the read-path latency story is read replication); only
1370
+ * write/batch/DDL statements throw this. */
1371
+ export class StoreUnavailable extends Error {
1372
+ constructor(
1373
+ /** The write's label — the verb + the target (e.g.
1374
+ * 'UPDATE sessions', 'batch (6 statements)'); never row data. */
1375
+ readonly operation: string,
1376
+ /** The confirmation budget the write exceeded, in ms. */
1377
+ readonly budgetMs: number,
1378
+ /** The statement's idempotency posture (the class note). */
1379
+ readonly retrySafe: boolean,
1380
+ ) {
1381
+ super(
1382
+ `the store write did not confirm within ${budgetMs} ms (${operation}) — the store is briefly unavailable; `
1383
+ + `the write may have landed — retry is safe for idempotent operations${retrySafe ? ' (this one is)' : ''}`,
1384
+ )
1385
+ this.name = 'StoreUnavailable'
1386
+ }
1387
+ }
1388
+
1357
1389
  /** The async store contract the routes consume. Every method mirrors a
1358
1390
  * sync counterpart in store.ts / entities.ts — same SQL, same
1359
1391
  * semantics, awaited. */