@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,92 @@
1
+ /**
2
+ * The trusted seam of the AaaS line (docs/rfc/aaas-rfc.md §4.4, threats S-1, S-3, S-13).
3
+ *
4
+ * Exactly one place turns "who is calling" into "whose ledger": `deriveSeamTenant`. It takes the caller an
5
+ * authenticator vouched for, and a per-organization key, and returns the ledger tenant as an HMAC digest
6
+ * (`deriveLedgerTenantId`). Nothing a request body, a header, a tool argument or a model output says is ever an
7
+ * input to it, and every API that needs a tenant takes the `SeamTenant` it returns, never a string.
8
+ *
9
+ * What the authenticator is, on the wire, is not decided here: the A3 boundary ADR chooses per-organization
10
+ * credentials or signed assertions. The authenticator is an in-process interface so that decision can land
11
+ * without touching the seam.
12
+ */
13
+ import { TenantStateError } from '../tenant-state/errors.js';
14
+ import { assertOrganizationId, deriveLedgerTenantId } from '../tenant-state/ids.js';
15
+ const MAX_PRINCIPAL_FIELD_CHARS = 256;
16
+ /** A keyring over a fixed set of keys. Key storage and rotation are deployment concerns and are not designed here. */
17
+ export class StaticOrganizationKeyring {
18
+ keys = new Map();
19
+ constructor(keys) {
20
+ for (const [organization, key] of Object.entries(keys)) {
21
+ assertOrganizationId(organization);
22
+ if (!(key instanceof Uint8Array) || key.byteLength < 32)
23
+ throw new RangeError(`the key of organization ${organization} must be at least 32 bytes`);
24
+ this.keys.set(organization, Uint8Array.from(key));
25
+ }
26
+ }
27
+ keyFor(organization) {
28
+ return this.keys.get(organization) ?? null;
29
+ }
30
+ }
31
+ export class SeamError extends TenantStateError {
32
+ }
33
+ /** The authenticated principal cannot be turned into a ledger tenant: an empty, oversized or malformed claim. */
34
+ export class InvalidPrincipalError extends SeamError {
35
+ constructor(message) {
36
+ super('GOTRY_SEAM_PRINCIPAL_INVALID', message);
37
+ }
38
+ }
39
+ /** The caller belongs to an organization this service holds no key for. */
40
+ export class UnknownOrganizationError extends SeamError {
41
+ constructor() {
42
+ super('GOTRY_SEAM_ORGANIZATION_UNKNOWN', 'the organization is not served here');
43
+ }
44
+ }
45
+ /** Something other than a `SeamTenant` reached an API that needs one. */
46
+ export class SeamTenantRequiredError extends SeamError {
47
+ constructor() {
48
+ super('GOTRY_SEAM_TENANT_REQUIRED', 'a tenant derived at the seam is required');
49
+ }
50
+ }
51
+ const issued = new WeakSet();
52
+ export function isSeamTenant(value) {
53
+ return typeof value === 'object' && value !== null && issued.has(value);
54
+ }
55
+ export function assertSeamTenant(value) {
56
+ if (!isSeamTenant(value))
57
+ throw new SeamTenantRequiredError();
58
+ return value;
59
+ }
60
+ /** `<organization>/<ledger tenant>`: the key for every in-process map and every metric. It contains no personal data. */
61
+ export function seamTenantKey(tenant) {
62
+ return `${assertSeamTenant(tenant).organization}/${tenant.ledgerTenant}`;
63
+ }
64
+ function principalField(name, value) {
65
+ if (typeof value !== 'string' || value.length === 0)
66
+ throw new InvalidPrincipalError(`${name} must be a non-empty string`);
67
+ if (value.length > MAX_PRINCIPAL_FIELD_CHARS)
68
+ throw new InvalidPrincipalError(`${name} must be at most ${MAX_PRINCIPAL_FIELD_CHARS} characters`);
69
+ return value;
70
+ }
71
+ /**
72
+ * The only derivation of a ledger tenant from a caller in the service.
73
+ *
74
+ * @throws InvalidTenantIdError for an organization id that is not path-safe, or an issuer or subject that is not well-formed
75
+ * @throws InvalidPrincipalError for a missing or oversized subject, issuer or scope
76
+ * @throws UnknownOrganizationError when the keyring holds no key for the organization
77
+ */
78
+ export function deriveSeamTenant(caller, keyring) {
79
+ if (typeof caller !== 'object' || caller === null)
80
+ throw new InvalidPrincipalError('the caller must be an object');
81
+ const organization = assertOrganizationId(caller.organization);
82
+ const issuer = principalField('issuer', caller.issuer);
83
+ const subject = principalField('subject', caller.subject);
84
+ principalField('scope', caller.scope);
85
+ const key = keyring.keyFor(organization);
86
+ if (key === null)
87
+ throw new UnknownOrganizationError();
88
+ const ledgerTenant = deriveLedgerTenantId(key, issuer, subject);
89
+ const tenant = Object.freeze({ organization, ledgerTenant });
90
+ issued.add(tenant);
91
+ return tenant;
92
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Durable store of the AaaS state layer: where off-node copies of a tenant ledger live
3
+ * (docs/rfc/aaas-rfc.md §4.3, decision Q4).
4
+ *
5
+ * The contract is the smallest an object store gives: create-if-absent, read, list by prefix
6
+ * and delete by prefix. Create-if-absent is the only write, so an object is immutable once
7
+ * it exists and a stale writer can never replace what a newer owner relies on. Epoch-conditional
8
+ * appends (durability mode D-b) are a later refinement of the same primitive.
9
+ *
10
+ * Every call must settle. A binding gives each request a deadline and fails with `DurableStoreUnavailableError` when it
11
+ * passes: the router renews a tenant's lease while a flush or a forget waits on the store, so a call that never returns
12
+ * holds the tenant on its replica until the process dies.
13
+ *
14
+ * `DirDurableStore` is the reference implementation on a local directory. It stands in for the
15
+ * S3-compatible store of the real deployment, whose binding must pass the same conformance
16
+ * checks before it holds a real copy.
17
+ */
18
+ import { randomBytes } from 'node:crypto';
19
+ import { link, mkdir, open, readFile, readdir, rm, rmdir } from 'node:fs/promises';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { DurableStoreUnavailableError } from './errors.js';
22
+ const MAX_KEY_LENGTH = 512;
23
+ const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
24
+ const PARTIAL_SEGMENT = /^(?:[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?$/;
25
+ /** Keys are `/`-separated segments of `[A-Za-z0-9._-]` that do not start with a dot, so no key can leave the store or touch its scratch area. */
26
+ export function assertDurableKey(key) {
27
+ if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_LENGTH || !key.split('/').every(segment => SEGMENT.test(segment))) {
28
+ throw new RangeError(`invalid durable key ${JSON.stringify(typeof key === 'string' ? key.slice(0, 80) : key)}`);
29
+ }
30
+ return key;
31
+ }
32
+ /** A prefix is complete segments followed by an optional partial one: `a/b/`, `a/b/snap-`, `a/b`. */
33
+ export function assertDurablePrefix(prefix) {
34
+ const segments = typeof prefix === 'string' ? prefix.split('/') : [];
35
+ const last = segments.pop();
36
+ if (typeof prefix !== 'string' || prefix.length === 0 || prefix.length > MAX_KEY_LENGTH ||
37
+ last === undefined || !PARTIAL_SEGMENT.test(last) || !segments.every(segment => SEGMENT.test(segment))) {
38
+ throw new RangeError(`invalid durable prefix ${JSON.stringify(typeof prefix === 'string' ? prefix.slice(0, 80) : prefix)}`);
39
+ }
40
+ return prefix;
41
+ }
42
+ /** A prefix to delete by: complete segments, so `acme/` can never take `acme2/`. */
43
+ export function assertDurableDeletePrefix(prefix) {
44
+ assertDurablePrefix(prefix);
45
+ if (!prefix.endsWith('/'))
46
+ throw new RangeError(`a durable delete takes whole segments and ends with "/", got ${JSON.stringify(prefix.slice(0, 80))}`);
47
+ return prefix;
48
+ }
49
+ function code(error) {
50
+ const value = error?.code;
51
+ return typeof value === 'string' ? value : undefined;
52
+ }
53
+ export class DirDurableStore {
54
+ root;
55
+ constructor(root) {
56
+ this.root = resolve(root);
57
+ }
58
+ async putIfAbsent(key, bytes) {
59
+ assertDurableKey(key);
60
+ const destination = this.pathOf(key);
61
+ const scratch = join(this.root, '.tmp');
62
+ const staged = join(scratch, randomBytes(8).toString('hex'));
63
+ try {
64
+ await mkdir(scratch, { recursive: true });
65
+ await mkdir(dirname(destination), { recursive: true });
66
+ const handle = await open(staged, 'wx');
67
+ try {
68
+ await handle.writeFile(bytes);
69
+ await handle.sync();
70
+ }
71
+ finally {
72
+ await handle.close();
73
+ }
74
+ // A hard link either creates the key with all its bytes or fails: a reader never sees a half-written object.
75
+ await link(staged, destination);
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ if (code(error) === 'EEXIST')
80
+ return false;
81
+ throw new DurableStoreUnavailableError(`durable put failed for ${key}`, { cause: error });
82
+ }
83
+ finally {
84
+ await rm(staged, { force: true });
85
+ }
86
+ }
87
+ async get(key) {
88
+ assertDurableKey(key);
89
+ try {
90
+ return await readFile(this.pathOf(key));
91
+ }
92
+ catch (error) {
93
+ if (code(error) === 'ENOENT' || code(error) === 'EISDIR' || code(error) === 'ENOTDIR')
94
+ return null;
95
+ throw new DurableStoreUnavailableError(`durable get failed for ${key}`, { cause: error });
96
+ }
97
+ }
98
+ async list(prefix) {
99
+ assertDurablePrefix(prefix);
100
+ const slash = prefix.lastIndexOf('/');
101
+ const directory = slash === -1 ? '' : prefix.slice(0, slash);
102
+ const keys = [];
103
+ try {
104
+ await this.walk(directory, keys);
105
+ }
106
+ catch (error) {
107
+ throw new DurableStoreUnavailableError(`durable list failed for ${prefix}`, { cause: error });
108
+ }
109
+ return keys.filter(key => key.startsWith(prefix)).sort();
110
+ }
111
+ async deletePrefix(prefix) {
112
+ assertDurableDeletePrefix(prefix);
113
+ const keys = await this.list(prefix);
114
+ try {
115
+ for (const key of keys)
116
+ await rm(this.pathOf(key), { force: true });
117
+ for (const directory of new Set(keys.map(key => dirname(key))))
118
+ await this.pruneEmptyDirectories(directory);
119
+ }
120
+ catch (error) {
121
+ throw new DurableStoreUnavailableError(`durable delete failed for ${prefix}`, { cause: error });
122
+ }
123
+ return keys.length;
124
+ }
125
+ pathOf(key) {
126
+ return join(this.root, ...key.split('/'));
127
+ }
128
+ async walk(directory, into) {
129
+ let entries;
130
+ try {
131
+ entries = await readdir(directory === '' ? this.root : this.pathOf(directory), { withFileTypes: true });
132
+ }
133
+ catch (error) {
134
+ if (code(error) === 'ENOENT' || code(error) === 'ENOTDIR')
135
+ return;
136
+ throw error;
137
+ }
138
+ for (const entry of entries) {
139
+ if (directory === '' && entry.name === '.tmp')
140
+ continue;
141
+ const child = directory === '' ? entry.name : `${directory}/${entry.name}`;
142
+ if (entry.isDirectory())
143
+ await this.walk(child, into);
144
+ else
145
+ into.push(child);
146
+ }
147
+ }
148
+ async pruneEmptyDirectories(directory) {
149
+ for (let current = directory; current !== '.' && current !== ''; current = dirname(current)) {
150
+ try {
151
+ await rmdir(this.pathOf(current));
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Epoch-scoped durable copies of a tenant ledger (docs/rfc/aaas-rfc.md §4.2 item 3 and §4.3, mode D-a).
3
+ *
4
+ * Every owner copies only into the prefix of its own epoch:
5
+ *
6
+ * <org>/<tenant>/e<epoch>/base.json where this epoch's ledger started: one pinned snapshot, or empty
7
+ * <org>/<tenant>/e<epoch>/snap-<n>.db an immutable, verified image of the ledger
8
+ * <org>/<tenant>/e<epoch>/snap-<n>.json its manifest; written last, so a manifest means a complete image
9
+ *
10
+ * A new owner never trusts what it finds beside its own epoch. It restores the newest verified snapshot
11
+ * of the newest lineage, then pins that exact snapshot in its own `base.json` before it serves a write.
12
+ * Anything a superseded owner uploads later goes into a dead prefix. A newer epoch's base never points at
13
+ * it, so it can never become authoritative. Integrity therefore rests on the lease epoch and on these
14
+ * rules, not on clocks. What can be lost under D-a is the tail of acknowledged writes after the last
15
+ * snapshot, which is the declared RPO.
16
+ *
17
+ * Only the ledger file is copied. Files beside it (planner working files, tickets, artifacts) are not
18
+ * replicated yet (RFC gap 6, fact F-2).
19
+ */
20
+ import Database from 'better-sqlite3';
21
+ import { createHash } from 'node:crypto';
22
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
25
+ import { ledgerDbPath, ledgerExists, openDb } from '../state-ledger.js';
26
+ import { CopyConflictError, RestoreVerificationError } from './errors.js';
27
+ import { leaseKeyFor, tenantRootFor } from './ids.js';
28
+ const MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024;
29
+ const HEX64 = /^[0-9a-f]{64}$/;
30
+ const TENANT_TABLES = ['events', 'projection_docs', 'projection_items', 'workflow_runs', 'workflow_steps', 'pending_writes'];
31
+ // ---- layout ------------------------------------------------------------------
32
+ export function epochPrefixOf(leaseKey, epoch) {
33
+ return `${leaseKey}/e${String(epoch).padStart(8, '0')}/`;
34
+ }
35
+ function snapshotKeyOf(leaseKey, epoch, seq, extension) {
36
+ return `${epochPrefixOf(leaseKey, epoch)}snap-${String(seq).padStart(6, '0')}.${extension}`;
37
+ }
38
+ function baseKeyOf(leaseKey, epoch) {
39
+ return `${epochPrefixOf(leaseKey, epoch)}base.json`;
40
+ }
41
+ function epochOfKey(objectKey) {
42
+ const match = /\/e(\d{8,})\/[^/]+$/.exec(objectKey);
43
+ return match ? Number(match[1]) : null;
44
+ }
45
+ function snapshotSeqsOf(keys, extension) {
46
+ const pattern = new RegExp(`/snap-(\\d{6})\\.${extension}$`);
47
+ const seqs = new Set();
48
+ for (const key of keys) {
49
+ const match = pattern.exec(key);
50
+ if (match)
51
+ seqs.add(Number(match[1]));
52
+ }
53
+ return [...seqs].sort((a, b) => b - a);
54
+ }
55
+ // ---- manifests -----------------------------------------------------------------
56
+ function isCount(value) {
57
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
58
+ }
59
+ function parseJson(bytes) {
60
+ try {
61
+ const value = JSON.parse(Buffer.from(bytes).toString('utf8'));
62
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function parseManifest(bytes, tenant, epoch, seq) {
69
+ const value = parseJson(bytes);
70
+ if (!value || value.v !== 1 || value.tenant !== tenant || value.epoch !== epoch || value.seq !== seq)
71
+ return null;
72
+ if (typeof value.sha256 !== 'string' || !HEX64.test(value.sha256))
73
+ return null;
74
+ if (![value.bytes, value.events, value.maxEventSeq, value.createdAtMs].every(isCount))
75
+ return null;
76
+ return value;
77
+ }
78
+ function parseBase(bytes, epoch) {
79
+ const value = parseJson(bytes);
80
+ if (!value || value.v !== 1 || value.epoch !== epoch)
81
+ return null;
82
+ if (value.from === null)
83
+ return { v: 1, epoch, from: null };
84
+ const from = value.from;
85
+ if (!from || typeof from !== 'object' || !isCount(from.epoch) || !isCount(from.seq) || from.epoch < 1 || from.epoch >= epoch || from.seq < 1)
86
+ return null;
87
+ if (typeof from.sha256 !== 'string' || !HEX64.test(from.sha256))
88
+ return null;
89
+ return { v: 1, epoch, from: { epoch: from.epoch, seq: from.seq, sha256: from.sha256 } };
90
+ }
91
+ function sha256Of(bytes) {
92
+ return createHash('sha256').update(bytes).digest('hex');
93
+ }
94
+ function refLabel(epoch, seq) {
95
+ return `e${epoch}/snap-${seq}`;
96
+ }
97
+ // ---- verification --------------------------------------------------------------
98
+ /**
99
+ * A ledger image is trusted only if SQLite finds it intact, it holds rows of this tenant and no
100
+ * other, and replaying its events reproduces its projections (the drill D properties). The checks
101
+ * run on a scratch copy, so the image itself is never opened for writing.
102
+ */
103
+ export function verifyLedgerImage(imagePath, ledgerTenant) {
104
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-verify-'));
105
+ try {
106
+ mkdirSync(dirname(ledgerDbPath(scratch)), { recursive: true });
107
+ copyFileSync(imagePath, ledgerDbPath(scratch));
108
+ const facts = inspectImage(ledgerDbPath(scratch), ledgerTenant);
109
+ assertFoldReproducesProjections(scratch, ledgerTenant);
110
+ return facts;
111
+ }
112
+ finally {
113
+ rmSync(scratch, { recursive: true, force: true });
114
+ }
115
+ }
116
+ function inspectImage(copyPath, ledgerTenant) {
117
+ const db = new Database(copyPath, { fileMustExist: true });
118
+ try {
119
+ const integrity = db.pragma('integrity_check');
120
+ if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok')
121
+ throw new RestoreVerificationError('ledger image failed integrity_check');
122
+ for (const table of TENANT_TABLES) {
123
+ const foreign = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE tenant_id <> ?`).get(ledgerTenant);
124
+ if (foreign.n !== 0)
125
+ throw new RestoreVerificationError(`ledger image holds rows of another tenant in ${table}`);
126
+ }
127
+ const row = db.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(seq), 0) AS m FROM events').get();
128
+ return { events: row.n, maxEventSeq: row.m };
129
+ }
130
+ catch (error) {
131
+ if (error instanceof RestoreVerificationError)
132
+ throw error;
133
+ throw new RestoreVerificationError(`ledger image is not a readable ledger: ${error.message}`);
134
+ }
135
+ finally {
136
+ db.close();
137
+ }
138
+ }
139
+ function assertFoldReproducesProjections(scratchRoot, ledgerTenant) {
140
+ let ledger;
141
+ try {
142
+ ledger = openDb(scratchRoot, ledgerTenant);
143
+ }
144
+ catch (error) {
145
+ throw new RestoreVerificationError(`ledger image could not be opened as a ledger: ${error.message}`);
146
+ }
147
+ try {
148
+ const before = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() });
149
+ ledger.rebuildProjections();
150
+ const after = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() });
151
+ if (before !== after)
152
+ throw new RestoreVerificationError('ledger image: replaying its events does not reproduce its projections');
153
+ }
154
+ finally {
155
+ ledger.close();
156
+ }
157
+ }
158
+ function verifyCandidate(bytes, manifest, ledgerTenant) {
159
+ if (bytes.byteLength !== manifest.bytes)
160
+ throw new RestoreVerificationError('size differs from its manifest');
161
+ if (sha256Of(bytes) !== manifest.sha256)
162
+ throw new RestoreVerificationError('checksum differs from its manifest');
163
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-candidate-'));
164
+ try {
165
+ const imagePath = join(scratch, 'image.db');
166
+ writeFileSync(imagePath, bytes);
167
+ const facts = verifyLedgerImage(imagePath, ledgerTenant);
168
+ if (facts.events !== manifest.events || facts.maxEventSeq !== manifest.maxEventSeq) {
169
+ throw new RestoreVerificationError('event count or last sequence differs from its manifest');
170
+ }
171
+ }
172
+ finally {
173
+ rmSync(scratch, { recursive: true, force: true });
174
+ }
175
+ }
176
+ // ---- publishing ----------------------------------------------------------------
177
+ /** The lease key of the context and the tenant root that was checked: every file operation below uses that root, not the string the caller passed. */
178
+ function checkedContext(context) {
179
+ const key = leaseKeyFor(context.organization, context.ledgerTenant);
180
+ if (context.lease.key !== key)
181
+ throw new RangeError(`the lease ${context.lease.key} does not belong to tenant ${key}`);
182
+ return { key, root: ownTenantRoot(context) };
183
+ }
184
+ /**
185
+ * `forgetTenant` deletes the tenant root recursively, and the root comes from the caller. It has to be the directory
186
+ * `tenantRootFor` lays out for exactly this tenant, `<state root>/orgs/<organization>/tenants/<tenant>`, so a mistaken or
187
+ * hostile context can name no other directory. The resolved path is returned: the string that was checked is the one that
188
+ * gets deleted, so a `..` behind a symlink cannot point the deletion somewhere else.
189
+ */
190
+ function ownTenantRoot(context) {
191
+ const expectedTail = tenantRootFor('/', context.organization, context.ledgerTenant);
192
+ const actual = resolve(context.tenantRoot);
193
+ if (!isAbsolute(context.tenantRoot) || !actual.endsWith(expectedTail)) {
194
+ throw new RangeError(`the tenant root ${JSON.stringify(context.tenantRoot.slice(0, 200))} is not the root of tenant ${leaseKeyFor(context.organization, context.ledgerTenant)}`);
195
+ }
196
+ return actual;
197
+ }
198
+ /**
199
+ * Takes a consistent image of the live ledger, verifies it, and publishes it into this holder's
200
+ * epoch prefix. The lease is checked before the work and again right before the upload.
201
+ */
202
+ export async function publishSnapshot(context, options = {}) {
203
+ const { key, root } = checkedContext(context);
204
+ const { durable, lease, ledgerTenant } = context;
205
+ lease.assertHeld();
206
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-snapshot-'));
207
+ try {
208
+ const imagePath = join(scratch, 'image.db');
209
+ const source = new Database(ledgerDbPath(root), { fileMustExist: true });
210
+ try {
211
+ source.pragma('busy_timeout = 8000');
212
+ await source.backup(imagePath);
213
+ }
214
+ finally {
215
+ source.close();
216
+ }
217
+ // The image must not depend on a -wal or -shm file that would not travel with it.
218
+ const image = new Database(imagePath);
219
+ try {
220
+ image.pragma('journal_mode = DELETE');
221
+ }
222
+ finally {
223
+ image.close();
224
+ }
225
+ const facts = verifyLedgerImage(imagePath, ledgerTenant);
226
+ const bytes = readFileSync(imagePath);
227
+ if (bytes.byteLength > MAX_SNAPSHOT_BYTES)
228
+ throw new RangeError(`ledger image of ${bytes.byteLength} bytes exceeds the ${MAX_SNAPSHOT_BYTES} byte limit`);
229
+ const sha256 = sha256Of(bytes);
230
+ lease.assertHeld();
231
+ const existing = await durable.list(`${epochPrefixOf(key, lease.epoch)}snap-`);
232
+ const seq = (snapshotSeqsOf(existing, 'db')[0] ?? 0) + 1;
233
+ const manifest = {
234
+ v: 1,
235
+ tenant: ledgerTenant,
236
+ epoch: lease.epoch,
237
+ seq,
238
+ sha256,
239
+ bytes: bytes.byteLength,
240
+ events: facts.events,
241
+ maxEventSeq: facts.maxEventSeq,
242
+ createdAtMs: (options.nowMs ?? Date.now)(),
243
+ };
244
+ if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'db'), bytes)) {
245
+ throw new CopyConflictError(`${refLabel(lease.epoch, seq)} already exists in the prefix of epoch ${lease.epoch}`);
246
+ }
247
+ if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'json'), Buffer.from(JSON.stringify(manifest)))) {
248
+ throw new CopyConflictError(`the manifest of ${refLabel(lease.epoch, seq)} already exists`);
249
+ }
250
+ // The upload took time. An image that landed after the lease lapsed may sit above the base a successor already
251
+ // pinned, and is then never read: reporting it as published would tell a caller its writes are safe when they are not.
252
+ lease.assertHeld();
253
+ return manifest;
254
+ }
255
+ finally {
256
+ rmSync(scratch, { recursive: true, force: true });
257
+ }
258
+ }
259
+ /** Pins where this epoch's ledger started. Written once, before the owner serves a write or publishes a snapshot. */
260
+ async function writeBase(context, key, from) {
261
+ context.lease.assertHeld();
262
+ const record = { v: 1, epoch: context.lease.epoch, from };
263
+ if (!await context.durable.putIfAbsent(baseKeyOf(key, context.lease.epoch), Buffer.from(JSON.stringify(record)))) {
264
+ throw new CopyConflictError(`the base of epoch ${context.lease.epoch} already exists`);
265
+ }
266
+ }
267
+ /**
268
+ * The newest verified snapshot of the newest lineage below `ownEpoch` and above the forgotten floor.
269
+ *
270
+ * The newest lineage is the highest epoch that left anything behind. Inside an epoch the newest snapshot
271
+ * that verifies wins; with none, the walk follows that epoch's `base.json` pin to the exact snapshot (and
272
+ * older ones of that epoch) it started from. A snapshot a superseded owner uploaded after a newer epoch
273
+ * pinned its base is above the pin and is never read.
274
+ *
275
+ * @throws RestoreVerificationError when copies exist but none of them verifies
276
+ */
277
+ export async function resolveRestorePoint(durable, leaseKey, ledgerTenant, ownEpoch, forgottenAtEpoch, verify) {
278
+ const keysByEpoch = new Map();
279
+ for (const objectKey of await durable.list(`${leaseKey}/e`)) {
280
+ const epoch = epochOfKey(objectKey);
281
+ if (epoch === null || epoch <= forgottenAtEpoch || epoch >= ownEpoch)
282
+ continue;
283
+ keysByEpoch.set(epoch, [...keysByEpoch.get(epoch) ?? [], objectKey]);
284
+ }
285
+ const epochsWithEvidence = [...keysByEpoch.keys()].sort((a, b) => b - a);
286
+ const skipped = [];
287
+ const visited = new Set();
288
+ let corrupt = false;
289
+ let epoch = epochsWithEvidence[0] ?? null;
290
+ let ceiling = Number.POSITIVE_INFINITY;
291
+ while (epoch !== null && !visited.has(epoch)) {
292
+ visited.add(epoch);
293
+ const keys = keysByEpoch.get(epoch) ?? await durable.list(epochPrefixOf(leaseKey, epoch));
294
+ for (const seq of snapshotSeqsOf(keys, 'json').filter(candidate => candidate <= ceiling)) {
295
+ const label = refLabel(epoch, seq);
296
+ const manifestBytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'json'));
297
+ const manifest = manifestBytes ? parseManifest(manifestBytes, ledgerTenant, epoch, seq) : null;
298
+ if (!manifest) {
299
+ skipped.push(`${label}: manifest is missing or malformed`);
300
+ corrupt = true;
301
+ continue;
302
+ }
303
+ const bytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'db'));
304
+ if (!bytes) {
305
+ skipped.push(`${label}: image is missing`);
306
+ corrupt = true;
307
+ continue;
308
+ }
309
+ try {
310
+ verify(bytes, manifest);
311
+ return { point: { manifest, bytes }, skipped };
312
+ }
313
+ catch (error) {
314
+ if (!(error instanceof RestoreVerificationError))
315
+ throw error;
316
+ skipped.push(`${label}: ${error.message}`);
317
+ corrupt = true;
318
+ }
319
+ }
320
+ const baseBytes = keys.includes(baseKeyOf(leaseKey, epoch)) ? await durable.get(baseKeyOf(leaseKey, epoch)) : null;
321
+ const base = baseBytes ? parseBase(baseBytes, epoch) : null;
322
+ if (base?.from === null)
323
+ break;
324
+ if (base?.from) {
325
+ if (base.from.epoch <= forgottenAtEpoch)
326
+ break;
327
+ epoch = base.from.epoch;
328
+ ceiling = base.from.seq;
329
+ continue;
330
+ }
331
+ skipped.push(`e${epoch}: base is missing or malformed`);
332
+ epoch = epochsWithEvidence.find(candidate => candidate < epoch) ?? null;
333
+ ceiling = Number.POSITIVE_INFINITY;
334
+ }
335
+ if (corrupt)
336
+ throw new RestoreVerificationError('durable copies exist but none of them verifies', skipped);
337
+ return { point: null, skipped };
338
+ }
339
+ function evictCachedLedger(tenantRoot, ledgerTenant) {
340
+ if (!ledgerExists(tenantRoot))
341
+ return;
342
+ try {
343
+ openDb(tenantRoot, ledgerTenant).close();
344
+ }
345
+ catch {
346
+ // A stale or damaged local file is about to be replaced; failing to open it changes nothing.
347
+ }
348
+ }
349
+ /**
350
+ * Makes the local ledger of a freshly acquired epoch equal to the newest verified durable copy, pins that
351
+ * copy in this epoch's `base.json`, and opens the ledger. Local files from an earlier tenure are always
352
+ * discarded: only the durable copy is authoritative.
353
+ */
354
+ export async function restoreTenantLedger(context) {
355
+ const { key, root: tenantRoot } = checkedContext(context);
356
+ const { durable, lease, ledgerTenant } = context;
357
+ lease.assertHeld();
358
+ const resolution = await resolveRestorePoint(durable, key, ledgerTenant, lease.epoch, lease.forgottenAtEpoch, (bytes, manifest) => verifyCandidate(bytes, manifest, ledgerTenant));
359
+ lease.assertHeld();
360
+ evictCachedLedger(tenantRoot, ledgerTenant);
361
+ const dbPath = ledgerDbPath(tenantRoot);
362
+ mkdirSync(dirname(dbPath), { recursive: true });
363
+ for (const suffix of ['', '-wal', '-shm', '.restoring'])
364
+ rmSync(`${dbPath}${suffix}`, { force: true });
365
+ const point = resolution.point;
366
+ if (point) {
367
+ writeFileSync(`${dbPath}.restoring`, point.bytes);
368
+ renameSync(`${dbPath}.restoring`, dbPath);
369
+ }
370
+ const restoredFrom = point ? { epoch: point.manifest.epoch, seq: point.manifest.seq, sha256: point.manifest.sha256 } : null;
371
+ await writeBase(context, key, restoredFrom);
372
+ return {
373
+ ledger: openDb(tenantRoot, ledgerTenant),
374
+ restoredFrom,
375
+ skipped: resolution.skipped,
376
+ facts: point ? { events: point.manifest.events, maxEventSeq: point.manifest.maxEventSeq } : null,
377
+ };
378
+ }
379
+ // ---- forgetting --------------------------------------------------------------------
380
+ /**
381
+ * Removes the local ledger files (the database and its -wal and -shm) of a tenure that flushed everything. Other files
382
+ * of the tenant's directory, such as a planner's working files, are not the ledger's and stay.
383
+ */
384
+ export function discardLocalLedger(context) {
385
+ const file = ledgerDbPath(checkedContext(context).root);
386
+ for (const path of [file, `${file}-wal`, `${file}-shm`])
387
+ rmSync(path, { force: true });
388
+ }
389
+ /**
390
+ * Forget means delete (red line 6): the floor goes into the lease record first, so no later restore can
391
+ * read an older copy even if a superseded owner uploads one afterwards; then every durable copy and the
392
+ * local files go; then the lease is handed back. The caller closes the tenant's ledger first.
393
+ */
394
+ export async function forgetTenant(context) {
395
+ const { key, root } = checkedContext(context);
396
+ context.lease.assertHeld();
397
+ const record = await context.lease.markForgotten();
398
+ // Only the epochs at or below the floor that was just raised are the forgotten tenure's. An owner that was paused between
399
+ // raising the floor and deleting wakes up after a successor took over: what the successor wrote is above the floor, and
400
+ // it is not this call's to delete.
401
+ const deletedObjects = await sweepMaskedCopies(context.durable, key, record.forgottenAtEpoch);
402
+ rmSync(root, { recursive: true, force: true });
403
+ await context.lease.release();
404
+ return { deletedObjects, floor: record.forgottenAtEpoch };
405
+ }
406
+ /**
407
+ * Deletes copies at or below the forgotten floor that a superseded owner uploaded after the forget. Every open of a
408
+ * tenant that was ever forgotten runs it; for one that is never opened again an operator runs it, once the previous
409
+ * owner's lease has certainly run out.
410
+ */
411
+ export async function sweepMaskedCopies(durable, leaseKey, forgottenAtEpoch) {
412
+ let deleted = 0;
413
+ const dead = new Set();
414
+ for (const objectKey of await durable.list(`${leaseKey}/e`)) {
415
+ const epoch = epochOfKey(objectKey);
416
+ if (epoch !== null && epoch <= forgottenAtEpoch)
417
+ dead.add(epoch);
418
+ }
419
+ for (const epoch of dead)
420
+ deleted += await durable.deletePrefix(epochPrefixOf(leaseKey, epoch));
421
+ return deleted;
422
+ }