@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 +8 -2
- package/package.json +1 -1
- package/src/store/d1.ts +184 -15
- package/src/store.ts +32 -0
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
|
|
15
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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. */
|