@danceiny/gotry 0.2.0-rc.31 → 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 (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,108 @@
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
+ export class TenantStateError extends Error {
10
+ code;
11
+ constructor(code, message, options) {
12
+ super(message, options);
13
+ this.name = new.target.name;
14
+ this.code = code;
15
+ }
16
+ }
17
+ /** Another holder has an unexpired lease. Carries the record so a router can forward to that owner. */
18
+ export class LeaseHeldError extends TenantStateError {
19
+ record;
20
+ constructor(record) {
21
+ super('GOTRY_TENANT_LEASE_HELD', `lease ${record.key} is held by ${record.owner} at epoch ${record.epoch}`);
22
+ this.record = record;
23
+ }
24
+ }
25
+ /** The caller does not hold the lease any more (expired, taken over, or never held). Terminal for that holder. */
26
+ export class LeaseLostError extends TenantStateError {
27
+ constructor(message) {
28
+ super('GOTRY_TENANT_LEASE_LOST', message);
29
+ }
30
+ }
31
+ /** The write path must not acknowledge: this process cannot prove it still owns the tenant. */
32
+ export class FencedError extends TenantStateError {
33
+ reason;
34
+ constructor(reason, key, epoch) {
35
+ super('GOTRY_TENANT_FENCED', `tenant ${key} epoch ${epoch} is fenced: ${reason}`);
36
+ this.reason = reason;
37
+ }
38
+ }
39
+ /** The lease store could not be reached or answered with a contention failure. Owners keep their local deadline and fail closed when it passes. */
40
+ export class LeaseStoreUnavailableError extends TenantStateError {
41
+ constructor(message, options) {
42
+ super('GOTRY_TENANT_LEASE_STORE_UNAVAILABLE', message, options);
43
+ }
44
+ }
45
+ /** An organization or tenant id is not in the fixed, path-safe format (threat S-3). */
46
+ export class InvalidTenantIdError extends TenantStateError {
47
+ constructor(message) {
48
+ super('GOTRY_TENANT_ID_INVALID', message);
49
+ }
50
+ }
51
+ /** The durable store failed or could not be reached. Replication lag grows; a stricter durability mode would refuse writes. */
52
+ export class DurableStoreUnavailableError extends TenantStateError {
53
+ constructor(message, options) {
54
+ super('GOTRY_TENANT_DURABLE_STORE_UNAVAILABLE', message, options);
55
+ }
56
+ }
57
+ /** A durable copy exists but cannot be trusted: a failed checksum, integrity, tenant or fold check. */
58
+ export class RestoreVerificationError extends TenantStateError {
59
+ skipped;
60
+ constructor(message, skipped = []) {
61
+ super('GOTRY_TENANT_RESTORE_INVALID', message);
62
+ this.skipped = skipped;
63
+ }
64
+ }
65
+ /** Something else wrote into this holder's own epoch prefix: the single-writer invariant is broken. */
66
+ export class CopyConflictError extends TenantStateError {
67
+ constructor(message) {
68
+ super('GOTRY_TENANT_COPY_CONFLICT', message);
69
+ }
70
+ }
71
+ /** The tenant is being handed off: in-flight work finishes, new work is refused and should be retried on the next owner. */
72
+ export class TenantDrainingError extends TenantStateError {
73
+ constructor(key) {
74
+ super('GOTRY_TENANT_DRAINING', `tenant ${key} is being handed off`);
75
+ }
76
+ }
77
+ /**
78
+ * A handoff gave up waiting for calls that were already running. The tenant serves again, nothing was flushed or released,
79
+ * and the caller decides: retry later, or abandon the tenant and give up its writes since the last snapshot. Waiting for
80
+ * ever would hold the lease for ever, because it is renewed through the drain.
81
+ */
82
+ export class DrainTimeoutError extends TenantStateError {
83
+ inFlight;
84
+ timeoutMs;
85
+ constructor(key, inFlight, timeoutMs) {
86
+ super('GOTRY_TENANT_DRAIN_TIMEOUT', `tenant ${key} still had ${inFlight} call(s) running after ${timeoutMs} ms of draining`);
87
+ this.inFlight = inFlight;
88
+ this.timeoutMs = timeoutMs;
89
+ }
90
+ }
91
+ /** The router was closed; no tenant can be opened through it any more. */
92
+ export class RouterClosedError extends TenantStateError {
93
+ constructor() {
94
+ super('GOTRY_TENANT_ROUTER_CLOSED', 'the tenant router is closed');
95
+ }
96
+ }
97
+ /**
98
+ * This replica already holds as many tenants as it was configured to. Each open tenant costs file descriptors (about
99
+ * three) and memory; past the process's descriptor limit every tenant, and every socket, would start failing at once.
100
+ * The request is refused before the lease or any file is touched and belongs on another replica or later.
101
+ */
102
+ export class ReplicaAtCapacityError extends TenantStateError {
103
+ limit;
104
+ constructor(limit) {
105
+ super('GOTRY_TENANT_REPLICA_AT_CAPACITY', 'this replica is serving as many tenants as it is configured to');
106
+ this.limit = limit;
107
+ }
108
+ }
@@ -0,0 +1,101 @@
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
+ import { createHmac } from 'node:crypto';
11
+ import { join, resolve } from 'node:path';
12
+ import { InvalidTenantIdError } from './errors.js';
13
+ const ORGANIZATION_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
14
+ const LEDGER_TENANT_ID = /^t-[0-9a-f]{32}$/;
15
+ const MIN_ORGANIZATION_KEY_BYTES = 32;
16
+ const MAX_CLAIM_BYTES = 512;
17
+ const CLAIM_SEPARATOR = Buffer.from([0]);
18
+ export function assertOrganizationId(value) {
19
+ if (typeof value !== 'string' || !ORGANIZATION_ID.test(value)) {
20
+ throw new InvalidTenantIdError('organization id must be 1-63 characters of [a-z0-9-] starting with [a-z0-9]');
21
+ }
22
+ return value;
23
+ }
24
+ export function assertLedgerTenantId(value) {
25
+ if (typeof value !== 'string' || !LEDGER_TENANT_ID.test(value)) {
26
+ throw new InvalidTenantIdError('ledger tenant id must be "t-" followed by 32 lowercase hex digits');
27
+ }
28
+ return value;
29
+ }
30
+ function claimBytes(name, value) {
31
+ if (typeof value !== 'string' || value.length === 0)
32
+ throw new InvalidTenantIdError(`${name} must be a non-empty string`);
33
+ if (value.includes('\u0000'))
34
+ throw new InvalidTenantIdError(`${name} must not contain NUL`);
35
+ const bytes = Buffer.from(value, 'utf8');
36
+ // A lone surrogate is replaced by U+FFFD on encoding, and two different subjects would then share one tenant.
37
+ if (bytes.toString('utf8') !== value)
38
+ throw new InvalidTenantIdError(`${name} must be well-formed Unicode`);
39
+ if (bytes.byteLength > MAX_CLAIM_BYTES)
40
+ throw new InvalidTenantIdError(`${name} must be at most ${MAX_CLAIM_BYTES} bytes`);
41
+ return bytes;
42
+ }
43
+ /**
44
+ * `t-` + the first 128 bits of HMAC-SHA256(K_org, issuer ‖ 0x00 ‖ subject).
45
+ *
46
+ * `issuer` and `subject` are matched byte for byte as the identity provider
47
+ * issued them; this layer does no Unicode normalization. `organizationKey` is
48
+ * the per-organization server-side key, so ids are neither guessable nor
49
+ * reversible by anyone who only sees a subject.
50
+ */
51
+ export function deriveLedgerTenantId(organizationKey, issuer, subject) {
52
+ if (!(organizationKey instanceof Uint8Array) || organizationKey.byteLength < MIN_ORGANIZATION_KEY_BYTES) {
53
+ throw new InvalidTenantIdError(`organization key must be at least ${MIN_ORGANIZATION_KEY_BYTES} bytes`);
54
+ }
55
+ const mac = createHmac('sha256', organizationKey)
56
+ .update(claimBytes('issuer', issuer))
57
+ .update(CLAIM_SEPARATOR)
58
+ .update(claimBytes('subject', subject))
59
+ .digest('hex');
60
+ return `t-${mac.slice(0, 32)}`;
61
+ }
62
+ const CACHE_PARTITION_CONTEXT = Buffer.from('gotry/prompt-cache-partition/v1', 'utf8');
63
+ const CACHE_PARTITION_LABEL = /^pc-[0-9a-f]{32}$/;
64
+ export function assertPromptCachePartition(value) {
65
+ if (typeof value !== 'string' || !CACHE_PARTITION_LABEL.test(value)) {
66
+ throw new InvalidTenantIdError('prompt cache partition must be "pc-" followed by 32 lowercase hex digits');
67
+ }
68
+ return value;
69
+ }
70
+ /**
71
+ * `pc-` + the first 128 bits of HMAC-SHA256(K_org, context ‖ 0x00 ‖ ledger tenant id).
72
+ *
73
+ * The label that goes into every planner prompt of one tenant, ahead of any tenant-controlled or tenant-derived byte
74
+ * (docs/rfc/aaas-rfc.md §4.8, threat S-16). A provider caches by prompt prefix; with this label placed after the shared
75
+ * instructions, the longest prefix two tenants can share ends before it. It is derived under the organization key with
76
+ * its own context string, so it is stable for one tenant on every replica (the tenant's own cache stays warm across a
77
+ * handoff), cannot be computed by anyone without the key, and is not the ledger tenant id the metrics and paths use.
78
+ */
79
+ export function derivePromptCachePartition(organizationKey, ledgerTenantId) {
80
+ if (!(organizationKey instanceof Uint8Array) || organizationKey.byteLength < MIN_ORGANIZATION_KEY_BYTES) {
81
+ throw new InvalidTenantIdError(`organization key must be at least ${MIN_ORGANIZATION_KEY_BYTES} bytes`);
82
+ }
83
+ const mac = createHmac('sha256', organizationKey)
84
+ .update(CACHE_PARTITION_CONTEXT)
85
+ .update(CLAIM_SEPARATOR)
86
+ .update(assertLedgerTenantId(ledgerTenantId))
87
+ .digest('hex');
88
+ return `pc-${mac.slice(0, 32)}`;
89
+ }
90
+ /** The lease key of one tenant: `<organization>/<ledger tenant>`. */
91
+ export function leaseKeyFor(organizationId, ledgerTenantId) {
92
+ return `${assertOrganizationId(organizationId)}/${assertLedgerTenantId(ledgerTenantId)}`;
93
+ }
94
+ /**
95
+ * The ownership and replication unit of one tenant on a replica. The directory
96
+ * holds a complete state root (`gotry-state/gotry-state.db` plus planner files),
97
+ * so the existing `openDb(root, ledgerTenantId)` opens it unchanged.
98
+ */
99
+ export function tenantRootFor(stateRoot, organizationId, ledgerTenantId) {
100
+ return join(resolve(stateRoot), 'orgs', assertOrganizationId(organizationId), 'tenants', assertLedgerTenantId(ledgerTenantId));
101
+ }
@@ -0,0 +1,11 @@
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
+ export * from './durable-store.js';
6
+ export * from './epoch-copies.js';
7
+ export * from './errors.js';
8
+ export * from './ids.js';
9
+ export * from './lease-store.js';
10
+ export * from './tenant-lease.js';
11
+ export * from './tenant-router.js';
@@ -0,0 +1,203 @@
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
+ import Database from 'better-sqlite3';
24
+ import { mkdirSync } from 'node:fs';
25
+ import { dirname } from 'node:path';
26
+ import { LeaseHeldError, LeaseLostError, LeaseStoreUnavailableError, TenantStateError } from './errors.js';
27
+ export const MIN_LEASE_TTL_MS = 100;
28
+ export const MAX_LEASE_TTL_MS = 10 * 60_000;
29
+ const MAX_TEXT_LENGTH = 200;
30
+ export function assertLeaseTtl(ttlMs) {
31
+ if (!Number.isInteger(ttlMs) || ttlMs < MIN_LEASE_TTL_MS || ttlMs > MAX_LEASE_TTL_MS) {
32
+ throw new RangeError(`lease ttl must be an integer within [${MIN_LEASE_TTL_MS}, ${MAX_LEASE_TTL_MS}] ms, got ${String(ttlMs)}`);
33
+ }
34
+ }
35
+ function assertText(name, value) {
36
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TEXT_LENGTH) {
37
+ throw new RangeError(`${name} must be a non-empty string of at most ${MAX_TEXT_LENGTH} characters`);
38
+ }
39
+ }
40
+ function assertEpoch(epoch) {
41
+ if (!Number.isInteger(epoch) || epoch < 1)
42
+ throw new RangeError(`epoch must be a positive integer, got ${String(epoch)}`);
43
+ }
44
+ function toRecord(row) {
45
+ return { key: row.key, owner: row.owner, epoch: row.epoch, leaseUntilMs: row.lease_until_ms, forgottenAtEpoch: row.forgotten_at_epoch };
46
+ }
47
+ /**
48
+ * The storage-level triggers carry the invariants a buggy caller must not be able
49
+ * to break: epochs move by exactly one, the forgotten floor never moves back, and
50
+ * a lease row (the epoch floor of its tenant) is never deleted.
51
+ */
52
+ const SCHEMA = `
53
+ CREATE TABLE IF NOT EXISTS leases (
54
+ key TEXT PRIMARY KEY,
55
+ owner TEXT NOT NULL,
56
+ epoch INTEGER NOT NULL CHECK (epoch >= 1),
57
+ lease_until_ms INTEGER NOT NULL,
58
+ forgotten_at_epoch INTEGER NOT NULL DEFAULT 0
59
+ ) WITHOUT ROWID;
60
+ CREATE TABLE IF NOT EXISTS lease_log (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ key TEXT NOT NULL,
63
+ op TEXT NOT NULL CHECK (op IN ('acquire', 'renew', 'release', 'forget')),
64
+ owner TEXT NOT NULL,
65
+ epoch INTEGER NOT NULL,
66
+ at_ms INTEGER NOT NULL
67
+ );
68
+ CREATE TRIGGER IF NOT EXISTS leases_epoch_forward_only
69
+ BEFORE UPDATE OF epoch ON leases
70
+ WHEN NEW.epoch <> OLD.epoch AND NEW.epoch <> OLD.epoch + 1
71
+ BEGIN SELECT RAISE(ABORT, 'lease epoch moves forward by exactly one'); END;
72
+ CREATE TRIGGER IF NOT EXISTS leases_forgotten_floor_forward_only
73
+ BEFORE UPDATE OF forgotten_at_epoch ON leases
74
+ WHEN NEW.forgotten_at_epoch < OLD.forgotten_at_epoch
75
+ BEGIN SELECT RAISE(ABORT, 'the forgotten floor never moves back'); END;
76
+ CREATE TRIGGER IF NOT EXISTS leases_never_deleted
77
+ BEFORE DELETE ON leases
78
+ BEGIN SELECT RAISE(ABORT, 'a lease row is the epoch floor of its tenant and is never deleted'); END;
79
+ `;
80
+ export class SqliteLeaseStore {
81
+ db;
82
+ nowMs;
83
+ constructor(options) {
84
+ mkdirSync(dirname(options.path), { recursive: true });
85
+ this.db = new Database(options.path);
86
+ this.nowMs = options.nowMs ?? Date.now;
87
+ // busy_timeout before anything contendable: sibling processes must wait for the write lock, not fail at once.
88
+ this.db.pragma('busy_timeout = 5000');
89
+ this.immediate(() => { this.db.exec(SCHEMA); });
90
+ }
91
+ close() {
92
+ this.db.close();
93
+ }
94
+ async read(key) {
95
+ assertText('key', key);
96
+ const row = this.guard(() => this.selectRow(key));
97
+ return row ? toRecord(row) : null;
98
+ }
99
+ async acquire(key, owner, ttlMs) {
100
+ assertText('key', key);
101
+ assertText('owner', owner);
102
+ assertLeaseTtl(ttlMs);
103
+ return this.immediate(() => {
104
+ const now = this.nowMs();
105
+ const row = this.selectRow(key);
106
+ if (row && row.lease_until_ms > now)
107
+ throw new LeaseHeldError(toRecord(row));
108
+ const epoch = row ? row.epoch + 1 : 1;
109
+ if (row) {
110
+ this.db.prepare('UPDATE leases SET owner = ?, epoch = ?, lease_until_ms = ? WHERE key = ?').run(owner, epoch, now + ttlMs, key);
111
+ }
112
+ else {
113
+ this.db.prepare('INSERT INTO leases (key, owner, epoch, lease_until_ms) VALUES (?, ?, ?, ?)').run(key, owner, epoch, now + ttlMs);
114
+ }
115
+ this.log(key, 'acquire', owner, epoch, now);
116
+ return { record: this.mustSelect(key), ttlMs };
117
+ });
118
+ }
119
+ async renew(key, owner, epoch, ttlMs) {
120
+ assertText('key', key);
121
+ assertText('owner', owner);
122
+ assertEpoch(epoch);
123
+ assertLeaseTtl(ttlMs);
124
+ return this.immediate(() => {
125
+ const now = this.nowMs();
126
+ const row = this.requireHolder(key, owner, epoch, now);
127
+ this.db.prepare('UPDATE leases SET lease_until_ms = ? WHERE key = ?').run(now + ttlMs, row.key);
128
+ this.log(key, 'renew', owner, epoch, now);
129
+ return { record: this.mustSelect(key), ttlMs };
130
+ });
131
+ }
132
+ async release(key, owner, epoch) {
133
+ assertText('key', key);
134
+ assertText('owner', owner);
135
+ assertEpoch(epoch);
136
+ return this.immediate(() => {
137
+ const now = this.nowMs();
138
+ const row = this.selectRow(key);
139
+ if (!row || row.owner !== owner || row.epoch !== epoch)
140
+ throw new LeaseLostError(`${key}: epoch ${epoch} is not held by ${owner}`);
141
+ this.db.prepare('UPDATE leases SET lease_until_ms = ? WHERE key = ?').run(Math.min(row.lease_until_ms, now), key);
142
+ this.log(key, 'release', owner, epoch, now);
143
+ return this.mustSelect(key);
144
+ });
145
+ }
146
+ async markForgotten(key, owner, epoch) {
147
+ assertText('key', key);
148
+ assertText('owner', owner);
149
+ assertEpoch(epoch);
150
+ return this.immediate(() => {
151
+ const now = this.nowMs();
152
+ const row = this.requireHolder(key, owner, epoch, now);
153
+ this.db.prepare('UPDATE leases SET forgotten_at_epoch = ? WHERE key = ?').run(Math.max(row.forgotten_at_epoch, epoch), key);
154
+ this.log(key, 'forget', owner, epoch, now);
155
+ return this.mustSelect(key);
156
+ });
157
+ }
158
+ /** Audit trail for suites: every granted operation in order. Not part of the LeaseStore contract. */
159
+ history(key) {
160
+ const rows = (key === undefined
161
+ ? this.db.prepare('SELECT key, op, owner, epoch, at_ms FROM lease_log ORDER BY id').all()
162
+ : this.db.prepare('SELECT key, op, owner, epoch, at_ms FROM lease_log WHERE key = ? ORDER BY id').all(key));
163
+ return rows.map(row => ({ key: row.key, op: row.op, owner: row.owner, epoch: row.epoch, atMs: row.at_ms }));
164
+ }
165
+ requireHolder(key, owner, epoch, now) {
166
+ const row = this.selectRow(key);
167
+ if (!row || row.owner !== owner || row.epoch !== epoch)
168
+ throw new LeaseLostError(`${key}: epoch ${epoch} is not held by ${owner}`);
169
+ if (row.lease_until_ms <= now)
170
+ throw new LeaseLostError(`${key}: the lease of epoch ${epoch} expired`);
171
+ return row;
172
+ }
173
+ selectRow(key) {
174
+ return this.db.prepare('SELECT key, owner, epoch, lease_until_ms, forgotten_at_epoch FROM leases WHERE key = ?').get(key);
175
+ }
176
+ mustSelect(key) {
177
+ const row = this.selectRow(key);
178
+ if (!row)
179
+ throw new LeaseStoreUnavailableError(`${key}: lease row vanished inside its transaction`);
180
+ return toRecord(row);
181
+ }
182
+ log(key, op, owner, epoch, atMs) {
183
+ this.db.prepare('INSERT INTO lease_log (key, op, owner, epoch, at_ms) VALUES (?, ?, ?, ?, ?)').run(key, op, owner, epoch, atMs);
184
+ }
185
+ /** Contention is an availability problem of the store; every other failure is a real error and passes through. */
186
+ guard(fn) {
187
+ try {
188
+ return fn();
189
+ }
190
+ catch (error) {
191
+ if (error instanceof TenantStateError)
192
+ throw error;
193
+ const code = error?.code;
194
+ if (typeof code === 'string' && code.startsWith('SQLITE_BUSY')) {
195
+ throw new LeaseStoreUnavailableError('lease store is busy', { cause: error });
196
+ }
197
+ throw error;
198
+ }
199
+ }
200
+ immediate(fn) {
201
+ return this.guard(() => this.db.transaction(fn).immediate());
202
+ }
203
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The holder side of a tenant lease (docs/rfc/aaas-rfc.md §4.2, items 2, 4 and 6).
3
+ *
4
+ * A replica owns a tenant between a successful `acquire` and the first of: an
5
+ * explicit `release`, a refused `renew`, or the local fence deadline. The deadline
6
+ * is measured on the replica's monotonic clock from BEFORE the request was sent,
7
+ * so the round trip, the store's clock and any skew between the two clocks only
8
+ * make the holder stop earlier:
9
+ *
10
+ * fence = sentAt + grantedTtl - margin
11
+ *
12
+ * `assertHeld()` is the synchronous gate of the write path. A write is
13
+ * acknowledged only if the gate passes before it and after it.
14
+ *
15
+ * What the local fence cannot see is a frozen clock: a process paused between its
16
+ * check and its acknowledgement. That case is covered by the durable layer, whose
17
+ * copies are scoped by epoch, not by this class.
18
+ */
19
+ import { performance } from 'node:perf_hooks';
20
+ import { FencedError, LeaseLostError } from './errors.js';
21
+ import { assertLeaseTtl } from './lease-store.js';
22
+ export const systemMonotonicClock = { nowMs: () => performance.now() };
23
+ export class TenantLease {
24
+ key;
25
+ replicaId;
26
+ epoch;
27
+ /** Durable copies of epochs up to this one are dead; fixed for the whole tenure, because only the holder can raise it. */
28
+ forgottenAtEpoch;
29
+ store;
30
+ clock;
31
+ ttlMs;
32
+ marginMs;
33
+ fenceAtMs;
34
+ state = 'held';
35
+ tail = Promise.resolve();
36
+ constructor(options, clock, record, fenceAtMs) {
37
+ this.key = options.key;
38
+ this.replicaId = options.replicaId;
39
+ this.epoch = record.epoch;
40
+ this.forgottenAtEpoch = record.forgottenAtEpoch;
41
+ this.store = options.store;
42
+ this.clock = clock;
43
+ this.ttlMs = options.ttlMs;
44
+ this.marginMs = options.marginMs;
45
+ this.fenceAtMs = fenceAtMs;
46
+ }
47
+ /**
48
+ * @throws LeaseHeldError when another holder has an unexpired lease
49
+ * @throws LeaseStoreUnavailableError when the store cannot be reached
50
+ */
51
+ static async acquire(options) {
52
+ assertLeaseTtl(options.ttlMs);
53
+ if (!Number.isInteger(options.marginMs) || options.marginMs < 0 || options.marginMs >= options.ttlMs) {
54
+ throw new RangeError(`lease margin must be an integer within [0, ttl), got ${String(options.marginMs)} for ttl ${options.ttlMs}`);
55
+ }
56
+ const clock = options.clock ?? systemMonotonicClock;
57
+ const sentAt = clock.nowMs();
58
+ const grant = await options.store.acquire(options.key, options.replicaId, options.ttlMs);
59
+ if (grant.record.owner !== options.replicaId || grant.record.key !== options.key) {
60
+ throw new LeaseLostError(`${options.key}: the store granted the lease to ${grant.record.owner}`);
61
+ }
62
+ const lease = new TenantLease(options, clock, grant.record, sentAt + grant.ttlMs - options.marginMs);
63
+ if (lease.fenceAtMs <= clock.nowMs()) {
64
+ // The round trip consumed the usable part of the lease; hand it back instead of holding an already fenced epoch.
65
+ await lease.release().catch(() => undefined);
66
+ throw new LeaseLostError(`${options.key}: the grant of epoch ${grant.record.epoch} was consumed by the round trip`);
67
+ }
68
+ return lease;
69
+ }
70
+ /** The synchronous gate of the write path. */
71
+ assertHeld() {
72
+ if (this.state === 'released')
73
+ throw this.fenced('lease_released');
74
+ if (this.state === 'lost')
75
+ throw this.fenced('lease_lost');
76
+ if (this.clock.nowMs() >= this.fenceAtMs)
77
+ throw this.fenced('lease_expired_locally');
78
+ }
79
+ isHeld() {
80
+ try {
81
+ this.assertHeld();
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /** Milliseconds until the local fence; zero or negative once fenced. */
89
+ remainingMs() {
90
+ return this.fenceAtMs - this.clock.nowMs();
91
+ }
92
+ /**
93
+ * Extends the lease; the epoch stays. A refusal is terminal for this holder. Any other
94
+ * failure leaves the deadline untouched: the holder fails closed when it passes, and a
95
+ * later successful renewal resumes it, because an unchanged epoch means nobody else held it.
96
+ */
97
+ renew() {
98
+ return this.serialize(async () => {
99
+ if (this.state !== 'held')
100
+ throw this.fenced(this.state === 'released' ? 'lease_released' : 'lease_lost');
101
+ const sentAt = this.clock.nowMs();
102
+ try {
103
+ const grant = await this.store.renew(this.key, this.replicaId, this.epoch, this.ttlMs);
104
+ if (grant.record.epoch !== this.epoch || grant.record.owner !== this.replicaId) {
105
+ throw new LeaseLostError(`${this.key}: renewal answered for epoch ${grant.record.epoch}`);
106
+ }
107
+ this.fenceAtMs = Math.max(this.fenceAtMs, sentAt + grant.ttlMs - this.marginMs);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof LeaseLostError)
111
+ this.state = 'lost';
112
+ throw error;
113
+ }
114
+ });
115
+ }
116
+ /**
117
+ * Graceful handoff step: stop acknowledging first, then tell the store, so the next owner
118
+ * can start at once. If the store cannot be reached the lease simply expires; the error is
119
+ * rethrown so the caller knows the handoff degraded to waiting out the TTL.
120
+ */
121
+ release() {
122
+ return this.serialize(async () => {
123
+ if (this.state !== 'held')
124
+ return;
125
+ this.state = 'released';
126
+ try {
127
+ await this.store.release(this.key, this.replicaId, this.epoch);
128
+ }
129
+ catch (error) {
130
+ if (error instanceof LeaseLostError)
131
+ return;
132
+ throw error;
133
+ }
134
+ });
135
+ }
136
+ /** Marks every durable copy up to this epoch dead (forget). Requires a lease that is provably held. */
137
+ async markForgotten() {
138
+ this.assertHeld();
139
+ return this.store.markForgotten(this.key, this.replicaId, this.epoch);
140
+ }
141
+ fenced(reason) {
142
+ return new FencedError(reason, this.key, this.epoch);
143
+ }
144
+ /** Renewals and the release of one holder run one at a time, in call order. */
145
+ serialize(step) {
146
+ const run = this.tail.then(step, step);
147
+ this.tail = run.then(() => undefined, () => undefined);
148
+ return run;
149
+ }
150
+ }