@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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.
Files changed (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Typed failures of the per-tenant state layer (AaaS A1, docs/rfc/aaas-rfc.md §4.2).
3
+ *
4
+ * `code` is the stable surface: callers branch on it or on `instanceof`, never on
5
+ * messages. Every class maps to an availability outcome and never to silent
6
+ * success: a holder that cannot prove it still owns a tenant must stop
7
+ * acknowledging writes for it.
8
+ */
9
+
10
+ import type { LeaseRecord } from './lease-store.ts'
11
+
12
+ export class TenantStateError extends Error {
13
+ readonly code: string
14
+
15
+ constructor(code: string, message: string, options?: { cause?: unknown }) {
16
+ super(message, options)
17
+ this.name = new.target.name
18
+ this.code = code
19
+ }
20
+ }
21
+
22
+ /** Another holder has an unexpired lease. Carries the record so a router can forward to that owner. */
23
+ export class LeaseHeldError extends TenantStateError {
24
+ readonly record: LeaseRecord
25
+
26
+ constructor(record: LeaseRecord) {
27
+ super('GOTRY_TENANT_LEASE_HELD', `lease ${record.key} is held by ${record.owner} at epoch ${record.epoch}`)
28
+ this.record = record
29
+ }
30
+ }
31
+
32
+ /** The caller does not hold the lease any more (expired, taken over, or never held). Terminal for that holder. */
33
+ export class LeaseLostError extends TenantStateError {
34
+ constructor(message: string) {
35
+ super('GOTRY_TENANT_LEASE_LOST', message)
36
+ }
37
+ }
38
+
39
+ export type FenceReason = 'lease_released' | 'lease_lost' | 'lease_expired_locally'
40
+
41
+ /** The write path must not acknowledge: this process cannot prove it still owns the tenant. */
42
+ export class FencedError extends TenantStateError {
43
+ readonly reason: FenceReason
44
+
45
+ constructor(reason: FenceReason, key: string, epoch: number) {
46
+ super('GOTRY_TENANT_FENCED', `tenant ${key} epoch ${epoch} is fenced: ${reason}`)
47
+ this.reason = reason
48
+ }
49
+ }
50
+
51
+ /** The lease store could not be reached or answered with a contention failure. Owners keep their local deadline and fail closed when it passes. */
52
+ export class LeaseStoreUnavailableError extends TenantStateError {
53
+ constructor(message: string, options?: { cause?: unknown }) {
54
+ super('GOTRY_TENANT_LEASE_STORE_UNAVAILABLE', message, options)
55
+ }
56
+ }
57
+
58
+ /** An organization or tenant id is not in the fixed, path-safe format (threat S-3). */
59
+ export class InvalidTenantIdError extends TenantStateError {
60
+ constructor(message: string) {
61
+ super('GOTRY_TENANT_ID_INVALID', message)
62
+ }
63
+ }
64
+
65
+ /** The durable store failed or could not be reached. Replication lag grows; a stricter durability mode would refuse writes. */
66
+ export class DurableStoreUnavailableError extends TenantStateError {
67
+ constructor(message: string, options?: { cause?: unknown }) {
68
+ super('GOTRY_TENANT_DURABLE_STORE_UNAVAILABLE', message, options)
69
+ }
70
+ }
71
+
72
+ /** A durable copy exists but cannot be trusted: a failed checksum, integrity, tenant or fold check. */
73
+ export class RestoreVerificationError extends TenantStateError {
74
+ readonly skipped: readonly string[]
75
+
76
+ constructor(message: string, skipped: readonly string[] = []) {
77
+ super('GOTRY_TENANT_RESTORE_INVALID', message)
78
+ this.skipped = skipped
79
+ }
80
+ }
81
+
82
+ /** Something else wrote into this holder's own epoch prefix: the single-writer invariant is broken. */
83
+ export class CopyConflictError extends TenantStateError {
84
+ constructor(message: string) {
85
+ super('GOTRY_TENANT_COPY_CONFLICT', message)
86
+ }
87
+ }
88
+
89
+ /** The tenant is being handed off: in-flight work finishes, new work is refused and should be retried on the next owner. */
90
+ export class TenantDrainingError extends TenantStateError {
91
+ constructor(key: string) {
92
+ super('GOTRY_TENANT_DRAINING', `tenant ${key} is being handed off`)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A handoff gave up waiting for calls that were already running. The tenant serves again, nothing was flushed or released,
98
+ * and the caller decides: retry later, or abandon the tenant and give up its writes since the last snapshot. Waiting for
99
+ * ever would hold the lease for ever, because it is renewed through the drain.
100
+ */
101
+ export class DrainTimeoutError extends TenantStateError {
102
+ readonly inFlight: number
103
+ readonly timeoutMs: number
104
+
105
+ constructor(key: string, inFlight: number, timeoutMs: number) {
106
+ super('GOTRY_TENANT_DRAIN_TIMEOUT', `tenant ${key} still had ${inFlight} call(s) running after ${timeoutMs} ms of draining`)
107
+ this.inFlight = inFlight
108
+ this.timeoutMs = timeoutMs
109
+ }
110
+ }
111
+
112
+ /** The router was closed; no tenant can be opened through it any more. */
113
+ export class RouterClosedError extends TenantStateError {
114
+ constructor() {
115
+ super('GOTRY_TENANT_ROUTER_CLOSED', 'the tenant router is closed')
116
+ }
117
+ }
118
+
119
+ /**
120
+ * This replica already holds as many tenants as it was configured to. Each open tenant costs file descriptors (about
121
+ * three) and memory; past the process's descriptor limit every tenant, and every socket, would start failing at once.
122
+ * The request is refused before the lease or any file is touched and belongs on another replica or later.
123
+ */
124
+ export class ReplicaAtCapacityError extends TenantStateError {
125
+ constructor(readonly limit: number) {
126
+ super('GOTRY_TENANT_REPLICA_AT_CAPACITY', 'this replica is serving as many tenants as it is configured to')
127
+ }
128
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tenant and organization identifiers of the AaaS state layer.
3
+ *
4
+ * A ledger tenant is one end user; an organization is the deploying party
5
+ * (hotelbyte's own vocabulary calls that a "tenant"). The tenant id is derived
6
+ * in the trusted seam from the authenticated principal and is never accepted
7
+ * from payloads, tools or the model. Every filesystem path of the layer is built
8
+ * from ids that passed the validators below and from nothing else (threat S-3).
9
+ */
10
+
11
+ import { createHmac } from 'node:crypto'
12
+ import { join, resolve } from 'node:path'
13
+ import { InvalidTenantIdError } from './errors.ts'
14
+
15
+ const ORGANIZATION_ID = /^[a-z0-9][a-z0-9-]{0,62}$/
16
+ const LEDGER_TENANT_ID = /^t-[0-9a-f]{32}$/
17
+ const MIN_ORGANIZATION_KEY_BYTES = 32
18
+ const MAX_CLAIM_BYTES = 512
19
+ const CLAIM_SEPARATOR = Buffer.from([0])
20
+
21
+ export function assertOrganizationId(value: unknown): string {
22
+ if (typeof value !== 'string' || !ORGANIZATION_ID.test(value)) {
23
+ throw new InvalidTenantIdError('organization id must be 1-63 characters of [a-z0-9-] starting with [a-z0-9]')
24
+ }
25
+ return value
26
+ }
27
+
28
+ export function assertLedgerTenantId(value: unknown): string {
29
+ if (typeof value !== 'string' || !LEDGER_TENANT_ID.test(value)) {
30
+ throw new InvalidTenantIdError('ledger tenant id must be "t-" followed by 32 lowercase hex digits')
31
+ }
32
+ return value
33
+ }
34
+
35
+ function claimBytes(name: string, value: unknown): Buffer {
36
+ if (typeof value !== 'string' || value.length === 0) throw new InvalidTenantIdError(`${name} must be a non-empty string`)
37
+ if (value.includes('\u0000')) throw new InvalidTenantIdError(`${name} must not contain NUL`)
38
+ const bytes = Buffer.from(value, 'utf8')
39
+ // A lone surrogate is replaced by U+FFFD on encoding, and two different subjects would then share one tenant.
40
+ if (bytes.toString('utf8') !== value) throw new InvalidTenantIdError(`${name} must be well-formed Unicode`)
41
+ if (bytes.byteLength > MAX_CLAIM_BYTES) throw new InvalidTenantIdError(`${name} must be at most ${MAX_CLAIM_BYTES} bytes`)
42
+ return bytes
43
+ }
44
+
45
+ /**
46
+ * `t-` + the first 128 bits of HMAC-SHA256(K_org, issuer ‖ 0x00 ‖ subject).
47
+ *
48
+ * `issuer` and `subject` are matched byte for byte as the identity provider
49
+ * issued them; this layer does no Unicode normalization. `organizationKey` is
50
+ * the per-organization server-side key, so ids are neither guessable nor
51
+ * reversible by anyone who only sees a subject.
52
+ */
53
+ export function deriveLedgerTenantId(organizationKey: Uint8Array, issuer: string, subject: string): string {
54
+ if (!(organizationKey instanceof Uint8Array) || organizationKey.byteLength < MIN_ORGANIZATION_KEY_BYTES) {
55
+ throw new InvalidTenantIdError(`organization key must be at least ${MIN_ORGANIZATION_KEY_BYTES} bytes`)
56
+ }
57
+ const mac = createHmac('sha256', organizationKey)
58
+ .update(claimBytes('issuer', issuer))
59
+ .update(CLAIM_SEPARATOR)
60
+ .update(claimBytes('subject', subject))
61
+ .digest('hex')
62
+ return `t-${mac.slice(0, 32)}`
63
+ }
64
+
65
+ const CACHE_PARTITION_CONTEXT = Buffer.from('gotry/prompt-cache-partition/v1', 'utf8')
66
+ const CACHE_PARTITION_LABEL = /^pc-[0-9a-f]{32}$/
67
+
68
+ export function assertPromptCachePartition(value: unknown): string {
69
+ if (typeof value !== 'string' || !CACHE_PARTITION_LABEL.test(value)) {
70
+ throw new InvalidTenantIdError('prompt cache partition must be "pc-" followed by 32 lowercase hex digits')
71
+ }
72
+ return value
73
+ }
74
+
75
+ /**
76
+ * `pc-` + the first 128 bits of HMAC-SHA256(K_org, context ‖ 0x00 ‖ ledger tenant id).
77
+ *
78
+ * The label that goes into every planner prompt of one tenant, ahead of any tenant-controlled or tenant-derived byte
79
+ * (docs/rfc/aaas-rfc.md §4.8, threat S-16). A provider caches by prompt prefix; with this label placed after the shared
80
+ * instructions, the longest prefix two tenants can share ends before it. It is derived under the organization key with
81
+ * its own context string, so it is stable for one tenant on every replica (the tenant's own cache stays warm across a
82
+ * handoff), cannot be computed by anyone without the key, and is not the ledger tenant id the metrics and paths use.
83
+ */
84
+ export function derivePromptCachePartition(organizationKey: Uint8Array, ledgerTenantId: string): string {
85
+ if (!(organizationKey instanceof Uint8Array) || organizationKey.byteLength < MIN_ORGANIZATION_KEY_BYTES) {
86
+ throw new InvalidTenantIdError(`organization key must be at least ${MIN_ORGANIZATION_KEY_BYTES} bytes`)
87
+ }
88
+ const mac = createHmac('sha256', organizationKey)
89
+ .update(CACHE_PARTITION_CONTEXT)
90
+ .update(CLAIM_SEPARATOR)
91
+ .update(assertLedgerTenantId(ledgerTenantId))
92
+ .digest('hex')
93
+ return `pc-${mac.slice(0, 32)}`
94
+ }
95
+
96
+ /** The lease key of one tenant: `<organization>/<ledger tenant>`. */
97
+ export function leaseKeyFor(organizationId: string, ledgerTenantId: string): string {
98
+ return `${assertOrganizationId(organizationId)}/${assertLedgerTenantId(ledgerTenantId)}`
99
+ }
100
+
101
+ /**
102
+ * The ownership and replication unit of one tenant on a replica. The directory
103
+ * holds a complete state root (`gotry-state/gotry-state.db` plus planner files),
104
+ * so the existing `openDb(root, ledgerTenantId)` opens it unchanged.
105
+ */
106
+ export function tenantRootFor(stateRoot: string, organizationId: string, ledgerTenantId: string): string {
107
+ return join(resolve(stateRoot), 'orgs', assertOrganizationId(organizationId), 'tenants', assertLedgerTenantId(ledgerTenantId))
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The per-tenant state layer of the AaaS line (docs/rfc/aaas-rfc.md): a single writer per tenant,
3
+ * fenced by a lease epoch, with epoch-scoped durable copies. Outside the kernel.
4
+ */
5
+
6
+ export * from './durable-store.ts'
7
+ export * from './epoch-copies.ts'
8
+ export * from './errors.ts'
9
+ export * from './ids.ts'
10
+ export * from './lease-store.ts'
11
+ export * from './tenant-lease.ts'
12
+ export * from './tenant-router.ts'
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Lease store of the AaaS state layer (docs/rfc/aaas-rfc.md §4.2, decision Q3).
3
+ *
4
+ * One record per tenant says which replica may write that tenant's ledger and at
5
+ * which epoch. The contract is a linearizable compare-and-set per key:
6
+ *
7
+ * - `acquire` succeeds only when no record exists or the record has expired on
8
+ * the STORE's clock, and then moves the epoch from n to n + 1;
9
+ * - `renew` keeps the epoch and succeeds only for the current, unexpired holder;
10
+ * - `release` expires the lease at once and keeps the epoch (the epoch floor);
11
+ * - epochs never decrease, and records are never deleted.
12
+ *
13
+ * Integrity of the tenant's data does not rest on clocks: durable copies are
14
+ * scoped by epoch (A1.2), so a stale holder can cost availability but cannot make
15
+ * its writes authoritative. The store's clock only decides who may try next.
16
+ *
17
+ * `SqliteLeaseStore` is the reference implementation: one SQLite file whose write
18
+ * lock serializes writers across processes on one host. It is what the offline
19
+ * suites and single-host drills run against. The production binding (hotel-be
20
+ * MySQL, one row per tenant, CAS on the primary) must pass the same conformance
21
+ * suite before it is used.
22
+ */
23
+
24
+ import Database from 'better-sqlite3'
25
+ import { mkdirSync } from 'node:fs'
26
+ import { dirname } from 'node:path'
27
+ import { LeaseHeldError, LeaseLostError, LeaseStoreUnavailableError, TenantStateError } from './errors.ts'
28
+
29
+ /** One row per tenant. `leaseUntilMs` is on the store's own clock; `epoch` only ever grows. */
30
+ export interface LeaseRecord {
31
+ readonly key: string
32
+ readonly owner: string
33
+ readonly epoch: number
34
+ readonly leaseUntilMs: number
35
+ /** Restore ignores durable copies of epochs up to and including this one; 0 when nothing was forgotten. */
36
+ readonly forgottenAtEpoch: number
37
+ }
38
+
39
+ export interface LeaseGrant {
40
+ readonly record: LeaseRecord
41
+ /** The duration the store granted. Holders measure it from before they sent the request. */
42
+ readonly ttlMs: number
43
+ }
44
+
45
+ export interface LeaseStore {
46
+ read(key: string): Promise<LeaseRecord | null>
47
+ /** @throws LeaseHeldError while another unexpired lease exists (the caller's own included). */
48
+ acquire(key: string, owner: string, ttlMs: number): Promise<LeaseGrant>
49
+ /** @throws LeaseLostError unless the caller holds that epoch and its lease has not expired. */
50
+ renew(key: string, owner: string, epoch: number, ttlMs: number): Promise<LeaseGrant>
51
+ /** Graceful handoff: expires the lease now and keeps the epoch. @throws LeaseLostError for a stale caller. */
52
+ release(key: string, owner: string, epoch: number): Promise<LeaseRecord>
53
+ /** Records that every durable copy up to `epoch` is dead. Only the current holder may. */
54
+ markForgotten(key: string, owner: string, epoch: number): Promise<LeaseRecord>
55
+ }
56
+
57
+ export const MIN_LEASE_TTL_MS = 100
58
+ export const MAX_LEASE_TTL_MS = 10 * 60_000
59
+ const MAX_TEXT_LENGTH = 200
60
+
61
+ export function assertLeaseTtl(ttlMs: number): void {
62
+ if (!Number.isInteger(ttlMs) || ttlMs < MIN_LEASE_TTL_MS || ttlMs > MAX_LEASE_TTL_MS) {
63
+ throw new RangeError(`lease ttl must be an integer within [${MIN_LEASE_TTL_MS}, ${MAX_LEASE_TTL_MS}] ms, got ${String(ttlMs)}`)
64
+ }
65
+ }
66
+
67
+ function assertText(name: string, value: unknown): void {
68
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TEXT_LENGTH) {
69
+ throw new RangeError(`${name} must be a non-empty string of at most ${MAX_TEXT_LENGTH} characters`)
70
+ }
71
+ }
72
+
73
+ function assertEpoch(epoch: number): void {
74
+ if (!Number.isInteger(epoch) || epoch < 1) throw new RangeError(`epoch must be a positive integer, got ${String(epoch)}`)
75
+ }
76
+
77
+ interface LeaseRow {
78
+ key: string
79
+ owner: string
80
+ epoch: number
81
+ lease_until_ms: number
82
+ forgotten_at_epoch: number
83
+ }
84
+
85
+ function toRecord(row: LeaseRow): LeaseRecord {
86
+ return { key: row.key, owner: row.owner, epoch: row.epoch, leaseUntilMs: row.lease_until_ms, forgottenAtEpoch: row.forgotten_at_epoch }
87
+ }
88
+
89
+ export interface LeaseHistoryEntry {
90
+ readonly key: string
91
+ readonly op: 'acquire' | 'renew' | 'release' | 'forget'
92
+ readonly owner: string
93
+ readonly epoch: number
94
+ readonly atMs: number
95
+ }
96
+
97
+ /**
98
+ * The storage-level triggers carry the invariants a buggy caller must not be able
99
+ * to break: epochs move by exactly one, the forgotten floor never moves back, and
100
+ * a lease row (the epoch floor of its tenant) is never deleted.
101
+ */
102
+ const SCHEMA = `
103
+ CREATE TABLE IF NOT EXISTS leases (
104
+ key TEXT PRIMARY KEY,
105
+ owner TEXT NOT NULL,
106
+ epoch INTEGER NOT NULL CHECK (epoch >= 1),
107
+ lease_until_ms INTEGER NOT NULL,
108
+ forgotten_at_epoch INTEGER NOT NULL DEFAULT 0
109
+ ) WITHOUT ROWID;
110
+ CREATE TABLE IF NOT EXISTS lease_log (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ key TEXT NOT NULL,
113
+ op TEXT NOT NULL CHECK (op IN ('acquire', 'renew', 'release', 'forget')),
114
+ owner TEXT NOT NULL,
115
+ epoch INTEGER NOT NULL,
116
+ at_ms INTEGER NOT NULL
117
+ );
118
+ CREATE TRIGGER IF NOT EXISTS leases_epoch_forward_only
119
+ BEFORE UPDATE OF epoch ON leases
120
+ WHEN NEW.epoch <> OLD.epoch AND NEW.epoch <> OLD.epoch + 1
121
+ BEGIN SELECT RAISE(ABORT, 'lease epoch moves forward by exactly one'); END;
122
+ CREATE TRIGGER IF NOT EXISTS leases_forgotten_floor_forward_only
123
+ BEFORE UPDATE OF forgotten_at_epoch ON leases
124
+ WHEN NEW.forgotten_at_epoch < OLD.forgotten_at_epoch
125
+ BEGIN SELECT RAISE(ABORT, 'the forgotten floor never moves back'); END;
126
+ CREATE TRIGGER IF NOT EXISTS leases_never_deleted
127
+ BEFORE DELETE ON leases
128
+ BEGIN SELECT RAISE(ABORT, 'a lease row is the epoch floor of its tenant and is never deleted'); END;
129
+ `
130
+
131
+ export interface SqliteLeaseStoreOptions {
132
+ readonly path: string
133
+ /** The store's clock. Injected so the offline suites control expiry; defaults to the wall clock. */
134
+ readonly nowMs?: () => number
135
+ }
136
+
137
+ export class SqliteLeaseStore implements LeaseStore {
138
+ private readonly db: Database.Database
139
+ private readonly nowMs: () => number
140
+
141
+ constructor(options: SqliteLeaseStoreOptions) {
142
+ mkdirSync(dirname(options.path), { recursive: true })
143
+ this.db = new Database(options.path)
144
+ this.nowMs = options.nowMs ?? Date.now
145
+ // busy_timeout before anything contendable: sibling processes must wait for the write lock, not fail at once.
146
+ this.db.pragma('busy_timeout = 5000')
147
+ this.immediate(() => { this.db.exec(SCHEMA) })
148
+ }
149
+
150
+ close(): void {
151
+ this.db.close()
152
+ }
153
+
154
+ async read(key: string): Promise<LeaseRecord | null> {
155
+ assertText('key', key)
156
+ const row = this.guard(() => this.selectRow(key))
157
+ return row ? toRecord(row) : null
158
+ }
159
+
160
+ async acquire(key: string, owner: string, ttlMs: number): Promise<LeaseGrant> {
161
+ assertText('key', key)
162
+ assertText('owner', owner)
163
+ assertLeaseTtl(ttlMs)
164
+ return this.immediate(() => {
165
+ const now = this.nowMs()
166
+ const row = this.selectRow(key)
167
+ if (row && row.lease_until_ms > now) throw new LeaseHeldError(toRecord(row))
168
+ const epoch = row ? row.epoch + 1 : 1
169
+ if (row) {
170
+ this.db.prepare('UPDATE leases SET owner = ?, epoch = ?, lease_until_ms = ? WHERE key = ?').run(owner, epoch, now + ttlMs, key)
171
+ } else {
172
+ this.db.prepare('INSERT INTO leases (key, owner, epoch, lease_until_ms) VALUES (?, ?, ?, ?)').run(key, owner, epoch, now + ttlMs)
173
+ }
174
+ this.log(key, 'acquire', owner, epoch, now)
175
+ return { record: this.mustSelect(key), ttlMs }
176
+ })
177
+ }
178
+
179
+ async renew(key: string, owner: string, epoch: number, ttlMs: number): Promise<LeaseGrant> {
180
+ assertText('key', key)
181
+ assertText('owner', owner)
182
+ assertEpoch(epoch)
183
+ assertLeaseTtl(ttlMs)
184
+ return this.immediate(() => {
185
+ const now = this.nowMs()
186
+ const row = this.requireHolder(key, owner, epoch, now)
187
+ this.db.prepare('UPDATE leases SET lease_until_ms = ? WHERE key = ?').run(now + ttlMs, row.key)
188
+ this.log(key, 'renew', owner, epoch, now)
189
+ return { record: this.mustSelect(key), ttlMs }
190
+ })
191
+ }
192
+
193
+ async release(key: string, owner: string, epoch: number): Promise<LeaseRecord> {
194
+ assertText('key', key)
195
+ assertText('owner', owner)
196
+ assertEpoch(epoch)
197
+ return this.immediate(() => {
198
+ const now = this.nowMs()
199
+ const row = this.selectRow(key)
200
+ if (!row || row.owner !== owner || row.epoch !== epoch) throw new LeaseLostError(`${key}: epoch ${epoch} is not held by ${owner}`)
201
+ this.db.prepare('UPDATE leases SET lease_until_ms = ? WHERE key = ?').run(Math.min(row.lease_until_ms, now), key)
202
+ this.log(key, 'release', owner, epoch, now)
203
+ return this.mustSelect(key)
204
+ })
205
+ }
206
+
207
+ async markForgotten(key: string, owner: string, epoch: number): Promise<LeaseRecord> {
208
+ assertText('key', key)
209
+ assertText('owner', owner)
210
+ assertEpoch(epoch)
211
+ return this.immediate(() => {
212
+ const now = this.nowMs()
213
+ const row = this.requireHolder(key, owner, epoch, now)
214
+ this.db.prepare('UPDATE leases SET forgotten_at_epoch = ? WHERE key = ?').run(Math.max(row.forgotten_at_epoch, epoch), key)
215
+ this.log(key, 'forget', owner, epoch, now)
216
+ return this.mustSelect(key)
217
+ })
218
+ }
219
+
220
+ /** Audit trail for suites: every granted operation in order. Not part of the LeaseStore contract. */
221
+ history(key?: string): LeaseHistoryEntry[] {
222
+ const rows = (key === undefined
223
+ ? this.db.prepare('SELECT key, op, owner, epoch, at_ms FROM lease_log ORDER BY id').all()
224
+ : this.db.prepare('SELECT key, op, owner, epoch, at_ms FROM lease_log WHERE key = ? ORDER BY id').all(key)) as Array<{ key: string; op: LeaseHistoryEntry['op']; owner: string; epoch: number; at_ms: number }>
225
+ return rows.map(row => ({ key: row.key, op: row.op, owner: row.owner, epoch: row.epoch, atMs: row.at_ms }))
226
+ }
227
+
228
+ private requireHolder(key: string, owner: string, epoch: number, now: number): LeaseRow {
229
+ const row = this.selectRow(key)
230
+ if (!row || row.owner !== owner || row.epoch !== epoch) throw new LeaseLostError(`${key}: epoch ${epoch} is not held by ${owner}`)
231
+ if (row.lease_until_ms <= now) throw new LeaseLostError(`${key}: the lease of epoch ${epoch} expired`)
232
+ return row
233
+ }
234
+
235
+ private selectRow(key: string): LeaseRow | undefined {
236
+ return this.db.prepare('SELECT key, owner, epoch, lease_until_ms, forgotten_at_epoch FROM leases WHERE key = ?').get(key) as LeaseRow | undefined
237
+ }
238
+
239
+ private mustSelect(key: string): LeaseRecord {
240
+ const row = this.selectRow(key)
241
+ if (!row) throw new LeaseStoreUnavailableError(`${key}: lease row vanished inside its transaction`)
242
+ return toRecord(row)
243
+ }
244
+
245
+ private log(key: string, op: LeaseHistoryEntry['op'], owner: string, epoch: number, atMs: number): void {
246
+ this.db.prepare('INSERT INTO lease_log (key, op, owner, epoch, at_ms) VALUES (?, ?, ?, ?, ?)').run(key, op, owner, epoch, atMs)
247
+ }
248
+
249
+ /** Contention is an availability problem of the store; every other failure is a real error and passes through. */
250
+ private guard<T>(fn: () => T): T {
251
+ try {
252
+ return fn()
253
+ } catch (error) {
254
+ if (error instanceof TenantStateError) throw error
255
+ const code = (error as { code?: unknown } | null)?.code
256
+ if (typeof code === 'string' && code.startsWith('SQLITE_BUSY')) {
257
+ throw new LeaseStoreUnavailableError('lease store is busy', { cause: error })
258
+ }
259
+ throw error
260
+ }
261
+ }
262
+
263
+ private immediate<T>(fn: () => T): T {
264
+ return this.guard(() => this.db.transaction(fn).immediate())
265
+ }
266
+ }