@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,171 @@
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
+
19
+ import { randomBytes } from 'node:crypto'
20
+ import { link, mkdir, open, readFile, readdir, rm, rmdir } from 'node:fs/promises'
21
+ import { dirname, join, resolve } from 'node:path'
22
+ import { DurableStoreUnavailableError } from './errors.ts'
23
+
24
+ export interface DurableStore {
25
+ /** Creates the object only if the key is absent. Resolves false when it already exists. */
26
+ putIfAbsent(key: string, bytes: Uint8Array): Promise<boolean>
27
+ get(key: string): Promise<Uint8Array | null>
28
+ /** Every key that starts with the prefix, sorted. */
29
+ list(prefix: string): Promise<string[]>
30
+ /**
31
+ * Deletes every key that starts with the prefix and returns how many it deleted. The prefix is whole segments and ends
32
+ * with `/`: a delete that stopped inside a name would take every neighbour that starts with the same letters.
33
+ */
34
+ deletePrefix(prefix: string): Promise<number>
35
+ }
36
+
37
+ const MAX_KEY_LENGTH = 512
38
+ const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/
39
+ const PARTIAL_SEGMENT = /^(?:[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?$/
40
+
41
+ /** 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. */
42
+ export function assertDurableKey(key: string): string {
43
+ if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_LENGTH || !key.split('/').every(segment => SEGMENT.test(segment))) {
44
+ throw new RangeError(`invalid durable key ${JSON.stringify(typeof key === 'string' ? key.slice(0, 80) : key)}`)
45
+ }
46
+ return key
47
+ }
48
+
49
+ /** A prefix is complete segments followed by an optional partial one: `a/b/`, `a/b/snap-`, `a/b`. */
50
+ export function assertDurablePrefix(prefix: string): string {
51
+ const segments = typeof prefix === 'string' ? prefix.split('/') : []
52
+ const last = segments.pop()
53
+ if (
54
+ typeof prefix !== 'string' || prefix.length === 0 || prefix.length > MAX_KEY_LENGTH ||
55
+ last === undefined || !PARTIAL_SEGMENT.test(last) || !segments.every(segment => SEGMENT.test(segment))
56
+ ) {
57
+ throw new RangeError(`invalid durable prefix ${JSON.stringify(typeof prefix === 'string' ? prefix.slice(0, 80) : prefix)}`)
58
+ }
59
+ return prefix
60
+ }
61
+
62
+ /** A prefix to delete by: complete segments, so `acme/` can never take `acme2/`. */
63
+ export function assertDurableDeletePrefix(prefix: string): string {
64
+ assertDurablePrefix(prefix)
65
+ if (!prefix.endsWith('/')) throw new RangeError(`a durable delete takes whole segments and ends with "/", got ${JSON.stringify(prefix.slice(0, 80))}`)
66
+ return prefix
67
+ }
68
+
69
+ function code(error: unknown): string | undefined {
70
+ const value = (error as { code?: unknown } | null)?.code
71
+ return typeof value === 'string' ? value : undefined
72
+ }
73
+
74
+ export class DirDurableStore implements DurableStore {
75
+ private readonly root: string
76
+
77
+ constructor(root: string) {
78
+ this.root = resolve(root)
79
+ }
80
+
81
+ async putIfAbsent(key: string, bytes: Uint8Array): Promise<boolean> {
82
+ assertDurableKey(key)
83
+ const destination = this.pathOf(key)
84
+ const scratch = join(this.root, '.tmp')
85
+ const staged = join(scratch, randomBytes(8).toString('hex'))
86
+ try {
87
+ await mkdir(scratch, { recursive: true })
88
+ await mkdir(dirname(destination), { recursive: true })
89
+ const handle = await open(staged, 'wx')
90
+ try {
91
+ await handle.writeFile(bytes)
92
+ await handle.sync()
93
+ } finally {
94
+ await handle.close()
95
+ }
96
+ // A hard link either creates the key with all its bytes or fails: a reader never sees a half-written object.
97
+ await link(staged, destination)
98
+ return true
99
+ } catch (error) {
100
+ if (code(error) === 'EEXIST') return false
101
+ throw new DurableStoreUnavailableError(`durable put failed for ${key}`, { cause: error })
102
+ } finally {
103
+ await rm(staged, { force: true })
104
+ }
105
+ }
106
+
107
+ async get(key: string): Promise<Uint8Array | null> {
108
+ assertDurableKey(key)
109
+ try {
110
+ return await readFile(this.pathOf(key))
111
+ } catch (error) {
112
+ if (code(error) === 'ENOENT' || code(error) === 'EISDIR' || code(error) === 'ENOTDIR') return null
113
+ throw new DurableStoreUnavailableError(`durable get failed for ${key}`, { cause: error })
114
+ }
115
+ }
116
+
117
+ async list(prefix: string): Promise<string[]> {
118
+ assertDurablePrefix(prefix)
119
+ const slash = prefix.lastIndexOf('/')
120
+ const directory = slash === -1 ? '' : prefix.slice(0, slash)
121
+ const keys: string[] = []
122
+ try {
123
+ await this.walk(directory, keys)
124
+ } catch (error) {
125
+ throw new DurableStoreUnavailableError(`durable list failed for ${prefix}`, { cause: error })
126
+ }
127
+ return keys.filter(key => key.startsWith(prefix)).sort()
128
+ }
129
+
130
+ async deletePrefix(prefix: string): Promise<number> {
131
+ assertDurableDeletePrefix(prefix)
132
+ const keys = await this.list(prefix)
133
+ try {
134
+ for (const key of keys) await rm(this.pathOf(key), { force: true })
135
+ for (const directory of new Set(keys.map(key => dirname(key)))) await this.pruneEmptyDirectories(directory)
136
+ } catch (error) {
137
+ throw new DurableStoreUnavailableError(`durable delete failed for ${prefix}`, { cause: error })
138
+ }
139
+ return keys.length
140
+ }
141
+
142
+ private pathOf(key: string): string {
143
+ return join(this.root, ...key.split('/'))
144
+ }
145
+
146
+ private async walk(directory: string, into: string[]): Promise<void> {
147
+ let entries
148
+ try {
149
+ entries = await readdir(directory === '' ? this.root : this.pathOf(directory), { withFileTypes: true })
150
+ } catch (error) {
151
+ if (code(error) === 'ENOENT' || code(error) === 'ENOTDIR') return
152
+ throw error
153
+ }
154
+ for (const entry of entries) {
155
+ if (directory === '' && entry.name === '.tmp') continue
156
+ const child = directory === '' ? entry.name : `${directory}/${entry.name}`
157
+ if (entry.isDirectory()) await this.walk(child, into)
158
+ else into.push(child)
159
+ }
160
+ }
161
+
162
+ private async pruneEmptyDirectories(directory: string): Promise<void> {
163
+ for (let current = directory; current !== '.' && current !== ''; current = dirname(current)) {
164
+ try {
165
+ await rmdir(this.pathOf(current))
166
+ } catch {
167
+ return
168
+ }
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,491 @@
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
+
21
+ import Database from 'better-sqlite3'
22
+ import { createHash } from 'node:crypto'
23
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
26
+ import { ledgerDbPath, ledgerExists, openDb, type StateLedger } from '../state-ledger.ts'
27
+ import type { DurableStore } from './durable-store.ts'
28
+ import { CopyConflictError, RestoreVerificationError } from './errors.ts'
29
+ import { leaseKeyFor, tenantRootFor } from './ids.ts'
30
+ import type { TenantLease } from './tenant-lease.ts'
31
+
32
+ const MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024
33
+ const HEX64 = /^[0-9a-f]{64}$/
34
+ const TENANT_TABLES = ['events', 'projection_docs', 'projection_items', 'workflow_runs', 'workflow_steps', 'pending_writes'] as const
35
+
36
+ export interface SnapshotRef {
37
+ readonly epoch: number
38
+ readonly seq: number
39
+ readonly sha256: string
40
+ }
41
+
42
+ export interface SnapshotManifest extends SnapshotRef {
43
+ readonly v: 1
44
+ readonly tenant: string
45
+ readonly bytes: number
46
+ readonly events: number
47
+ readonly maxEventSeq: number
48
+ /** Diagnostic only: no decision reads it. */
49
+ readonly createdAtMs: number
50
+ }
51
+
52
+ interface BaseRecord {
53
+ readonly v: 1
54
+ readonly epoch: number
55
+ readonly from: SnapshotRef | null
56
+ }
57
+
58
+ export interface LedgerImageFacts {
59
+ readonly events: number
60
+ readonly maxEventSeq: number
61
+ }
62
+
63
+ export interface EpochCopyContext {
64
+ readonly durable: DurableStore
65
+ readonly lease: TenantLease
66
+ readonly organization: string
67
+ readonly ledgerTenant: string
68
+ /** The tenant's state root: its ledger is `<tenantRoot>/gotry-state/gotry-state.db`. */
69
+ readonly tenantRoot: string
70
+ }
71
+
72
+ // ---- layout ------------------------------------------------------------------
73
+
74
+ export function epochPrefixOf(leaseKey: string, epoch: number): string {
75
+ return `${leaseKey}/e${String(epoch).padStart(8, '0')}/`
76
+ }
77
+
78
+ function snapshotKeyOf(leaseKey: string, epoch: number, seq: number, extension: 'db' | 'json'): string {
79
+ return `${epochPrefixOf(leaseKey, epoch)}snap-${String(seq).padStart(6, '0')}.${extension}`
80
+ }
81
+
82
+ function baseKeyOf(leaseKey: string, epoch: number): string {
83
+ return `${epochPrefixOf(leaseKey, epoch)}base.json`
84
+ }
85
+
86
+ function epochOfKey(objectKey: string): number | null {
87
+ const match = /\/e(\d{8,})\/[^/]+$/.exec(objectKey)
88
+ return match ? Number(match[1]) : null
89
+ }
90
+
91
+ function snapshotSeqsOf(keys: readonly string[], extension: 'db' | 'json'): number[] {
92
+ const pattern = new RegExp(`/snap-(\\d{6})\\.${extension}$`)
93
+ const seqs = new Set<number>()
94
+ for (const key of keys) {
95
+ const match = pattern.exec(key)
96
+ if (match) seqs.add(Number(match[1]))
97
+ }
98
+ return [...seqs].sort((a, b) => b - a)
99
+ }
100
+
101
+ // ---- manifests -----------------------------------------------------------------
102
+
103
+ function isCount(value: unknown): value is number {
104
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0
105
+ }
106
+
107
+ function parseJson(bytes: Uint8Array): Record<string, unknown> | null {
108
+ try {
109
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString('utf8'))
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ function parseManifest(bytes: Uint8Array, tenant: string, epoch: number, seq: number): SnapshotManifest | null {
117
+ const value = parseJson(bytes)
118
+ if (!value || value.v !== 1 || value.tenant !== tenant || value.epoch !== epoch || value.seq !== seq) return null
119
+ if (typeof value.sha256 !== 'string' || !HEX64.test(value.sha256)) return null
120
+ if (![value.bytes, value.events, value.maxEventSeq, value.createdAtMs].every(isCount)) return null
121
+ return value as unknown as SnapshotManifest
122
+ }
123
+
124
+ function parseBase(bytes: Uint8Array, epoch: number): BaseRecord | null {
125
+ const value = parseJson(bytes)
126
+ if (!value || value.v !== 1 || value.epoch !== epoch) return null
127
+ if (value.from === null) return { v: 1, epoch, from: null }
128
+ const from = value.from as Record<string, unknown> | undefined
129
+ if (!from || typeof from !== 'object' || !isCount(from.epoch) || !isCount(from.seq) || from.epoch < 1 || from.epoch >= epoch || from.seq < 1) return null
130
+ if (typeof from.sha256 !== 'string' || !HEX64.test(from.sha256)) return null
131
+ return { v: 1, epoch, from: { epoch: from.epoch, seq: from.seq, sha256: from.sha256 } }
132
+ }
133
+
134
+ function sha256Of(bytes: Uint8Array): string {
135
+ return createHash('sha256').update(bytes).digest('hex')
136
+ }
137
+
138
+ function refLabel(epoch: number, seq: number): string {
139
+ return `e${epoch}/snap-${seq}`
140
+ }
141
+
142
+ // ---- verification --------------------------------------------------------------
143
+
144
+ /**
145
+ * A ledger image is trusted only if SQLite finds it intact, it holds rows of this tenant and no
146
+ * other, and replaying its events reproduces its projections (the drill D properties). The checks
147
+ * run on a scratch copy, so the image itself is never opened for writing.
148
+ */
149
+ export function verifyLedgerImage(imagePath: string, ledgerTenant: string): LedgerImageFacts {
150
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-verify-'))
151
+ try {
152
+ mkdirSync(dirname(ledgerDbPath(scratch)), { recursive: true })
153
+ copyFileSync(imagePath, ledgerDbPath(scratch))
154
+ const facts = inspectImage(ledgerDbPath(scratch), ledgerTenant)
155
+ assertFoldReproducesProjections(scratch, ledgerTenant)
156
+ return facts
157
+ } finally {
158
+ rmSync(scratch, { recursive: true, force: true })
159
+ }
160
+ }
161
+
162
+ function inspectImage(copyPath: string, ledgerTenant: string): LedgerImageFacts {
163
+ const db = new Database(copyPath, { fileMustExist: true })
164
+ try {
165
+ const integrity = db.pragma('integrity_check') as Array<{ integrity_check: string }>
166
+ if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') throw new RestoreVerificationError('ledger image failed integrity_check')
167
+ for (const table of TENANT_TABLES) {
168
+ const foreign = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE tenant_id <> ?`).get(ledgerTenant) as { n: number }
169
+ if (foreign.n !== 0) throw new RestoreVerificationError(`ledger image holds rows of another tenant in ${table}`)
170
+ }
171
+ const row = db.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(seq), 0) AS m FROM events').get() as { n: number; m: number }
172
+ return { events: row.n, maxEventSeq: row.m }
173
+ } catch (error) {
174
+ if (error instanceof RestoreVerificationError) throw error
175
+ throw new RestoreVerificationError(`ledger image is not a readable ledger: ${(error as Error).message}`)
176
+ } finally {
177
+ db.close()
178
+ }
179
+ }
180
+
181
+ function assertFoldReproducesProjections(scratchRoot: string, ledgerTenant: string): void {
182
+ let ledger: StateLedger
183
+ try {
184
+ ledger = openDb(scratchRoot, ledgerTenant)
185
+ } catch (error) {
186
+ throw new RestoreVerificationError(`ledger image could not be opened as a ledger: ${(error as Error).message}`)
187
+ }
188
+ try {
189
+ const before = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() })
190
+ ledger.rebuildProjections()
191
+ const after = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() })
192
+ if (before !== after) throw new RestoreVerificationError('ledger image: replaying its events does not reproduce its projections')
193
+ } finally {
194
+ ledger.close()
195
+ }
196
+ }
197
+
198
+ function verifyCandidate(bytes: Uint8Array, manifest: SnapshotManifest, ledgerTenant: string): void {
199
+ if (bytes.byteLength !== manifest.bytes) throw new RestoreVerificationError('size differs from its manifest')
200
+ if (sha256Of(bytes) !== manifest.sha256) throw new RestoreVerificationError('checksum differs from its manifest')
201
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-candidate-'))
202
+ try {
203
+ const imagePath = join(scratch, 'image.db')
204
+ writeFileSync(imagePath, bytes)
205
+ const facts = verifyLedgerImage(imagePath, ledgerTenant)
206
+ if (facts.events !== manifest.events || facts.maxEventSeq !== manifest.maxEventSeq) {
207
+ throw new RestoreVerificationError('event count or last sequence differs from its manifest')
208
+ }
209
+ } finally {
210
+ rmSync(scratch, { recursive: true, force: true })
211
+ }
212
+ }
213
+
214
+ // ---- publishing ----------------------------------------------------------------
215
+
216
+ /** 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. */
217
+ function checkedContext(context: EpochCopyContext): { readonly key: string; readonly root: string } {
218
+ const key = leaseKeyFor(context.organization, context.ledgerTenant)
219
+ if (context.lease.key !== key) throw new RangeError(`the lease ${context.lease.key} does not belong to tenant ${key}`)
220
+ return { key, root: ownTenantRoot(context) }
221
+ }
222
+
223
+ /**
224
+ * `forgetTenant` deletes the tenant root recursively, and the root comes from the caller. It has to be the directory
225
+ * `tenantRootFor` lays out for exactly this tenant, `<state root>/orgs/<organization>/tenants/<tenant>`, so a mistaken or
226
+ * hostile context can name no other directory. The resolved path is returned: the string that was checked is the one that
227
+ * gets deleted, so a `..` behind a symlink cannot point the deletion somewhere else.
228
+ */
229
+ function ownTenantRoot(context: EpochCopyContext): string {
230
+ const expectedTail = tenantRootFor('/', context.organization, context.ledgerTenant)
231
+ const actual = resolve(context.tenantRoot)
232
+ if (!isAbsolute(context.tenantRoot) || !actual.endsWith(expectedTail)) {
233
+ throw new RangeError(`the tenant root ${JSON.stringify(context.tenantRoot.slice(0, 200))} is not the root of tenant ${leaseKeyFor(context.organization, context.ledgerTenant)}`)
234
+ }
235
+ return actual
236
+ }
237
+
238
+ /**
239
+ * Takes a consistent image of the live ledger, verifies it, and publishes it into this holder's
240
+ * epoch prefix. The lease is checked before the work and again right before the upload.
241
+ */
242
+ export async function publishSnapshot(context: EpochCopyContext, options: { nowMs?: () => number } = {}): Promise<SnapshotManifest> {
243
+ const { key, root } = checkedContext(context)
244
+ const { durable, lease, ledgerTenant } = context
245
+ lease.assertHeld()
246
+ const scratch = mkdtempSync(join(tmpdir(), 'gotry-snapshot-'))
247
+ try {
248
+ const imagePath = join(scratch, 'image.db')
249
+ const source = new Database(ledgerDbPath(root), { fileMustExist: true })
250
+ try {
251
+ source.pragma('busy_timeout = 8000')
252
+ await source.backup(imagePath)
253
+ } finally {
254
+ source.close()
255
+ }
256
+ // The image must not depend on a -wal or -shm file that would not travel with it.
257
+ const image = new Database(imagePath)
258
+ try {
259
+ image.pragma('journal_mode = DELETE')
260
+ } finally {
261
+ image.close()
262
+ }
263
+ const facts = verifyLedgerImage(imagePath, ledgerTenant)
264
+ const bytes = readFileSync(imagePath)
265
+ if (bytes.byteLength > MAX_SNAPSHOT_BYTES) throw new RangeError(`ledger image of ${bytes.byteLength} bytes exceeds the ${MAX_SNAPSHOT_BYTES} byte limit`)
266
+ const sha256 = sha256Of(bytes)
267
+
268
+ lease.assertHeld()
269
+ const existing = await durable.list(`${epochPrefixOf(key, lease.epoch)}snap-`)
270
+ const seq = (snapshotSeqsOf(existing, 'db')[0] ?? 0) + 1
271
+ const manifest: SnapshotManifest = {
272
+ v: 1,
273
+ tenant: ledgerTenant,
274
+ epoch: lease.epoch,
275
+ seq,
276
+ sha256,
277
+ bytes: bytes.byteLength,
278
+ events: facts.events,
279
+ maxEventSeq: facts.maxEventSeq,
280
+ createdAtMs: (options.nowMs ?? Date.now)(),
281
+ }
282
+ if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'db'), bytes)) {
283
+ throw new CopyConflictError(`${refLabel(lease.epoch, seq)} already exists in the prefix of epoch ${lease.epoch}`)
284
+ }
285
+ if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'json'), Buffer.from(JSON.stringify(manifest)))) {
286
+ throw new CopyConflictError(`the manifest of ${refLabel(lease.epoch, seq)} already exists`)
287
+ }
288
+ // The upload took time. An image that landed after the lease lapsed may sit above the base a successor already
289
+ // pinned, and is then never read: reporting it as published would tell a caller its writes are safe when they are not.
290
+ lease.assertHeld()
291
+ return manifest
292
+ } finally {
293
+ rmSync(scratch, { recursive: true, force: true })
294
+ }
295
+ }
296
+
297
+ /** Pins where this epoch's ledger started. Written once, before the owner serves a write or publishes a snapshot. */
298
+ async function writeBase(context: EpochCopyContext, key: string, from: SnapshotRef | null): Promise<void> {
299
+ context.lease.assertHeld()
300
+ const record: BaseRecord = { v: 1, epoch: context.lease.epoch, from }
301
+ if (!await context.durable.putIfAbsent(baseKeyOf(key, context.lease.epoch), Buffer.from(JSON.stringify(record)))) {
302
+ throw new CopyConflictError(`the base of epoch ${context.lease.epoch} already exists`)
303
+ }
304
+ }
305
+
306
+ // ---- restoring -------------------------------------------------------------------
307
+
308
+ export interface RestorePoint {
309
+ readonly manifest: SnapshotManifest
310
+ /** The verified image. */
311
+ readonly bytes: Uint8Array
312
+ }
313
+
314
+ export interface RestoreResolution {
315
+ /** `null` means the lineage starts empty. */
316
+ readonly point: RestorePoint | null
317
+ /** Snapshots that were passed over, with the reason; an operator alert, not an error. */
318
+ readonly skipped: readonly string[]
319
+ }
320
+
321
+ /**
322
+ * The newest verified snapshot of the newest lineage below `ownEpoch` and above the forgotten floor.
323
+ *
324
+ * The newest lineage is the highest epoch that left anything behind. Inside an epoch the newest snapshot
325
+ * that verifies wins; with none, the walk follows that epoch's `base.json` pin to the exact snapshot (and
326
+ * older ones of that epoch) it started from. A snapshot a superseded owner uploaded after a newer epoch
327
+ * pinned its base is above the pin and is never read.
328
+ *
329
+ * @throws RestoreVerificationError when copies exist but none of them verifies
330
+ */
331
+ export async function resolveRestorePoint(
332
+ durable: DurableStore,
333
+ leaseKey: string,
334
+ ledgerTenant: string,
335
+ ownEpoch: number,
336
+ forgottenAtEpoch: number,
337
+ verify: (bytes: Uint8Array, manifest: SnapshotManifest) => void,
338
+ ): Promise<RestoreResolution> {
339
+ const keysByEpoch = new Map<number, string[]>()
340
+ for (const objectKey of await durable.list(`${leaseKey}/e`)) {
341
+ const epoch = epochOfKey(objectKey)
342
+ if (epoch === null || epoch <= forgottenAtEpoch || epoch >= ownEpoch) continue
343
+ keysByEpoch.set(epoch, [...keysByEpoch.get(epoch) ?? [], objectKey])
344
+ }
345
+ const epochsWithEvidence = [...keysByEpoch.keys()].sort((a, b) => b - a)
346
+ const skipped: string[] = []
347
+ const visited = new Set<number>()
348
+ let corrupt = false
349
+ let epoch: number | null = epochsWithEvidence[0] ?? null
350
+ let ceiling = Number.POSITIVE_INFINITY
351
+
352
+ while (epoch !== null && !visited.has(epoch)) {
353
+ visited.add(epoch)
354
+ const keys: string[] = keysByEpoch.get(epoch) ?? await durable.list(epochPrefixOf(leaseKey, epoch))
355
+ for (const seq of snapshotSeqsOf(keys, 'json').filter(candidate => candidate <= ceiling)) {
356
+ const label = refLabel(epoch, seq)
357
+ const manifestBytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'json'))
358
+ const manifest = manifestBytes ? parseManifest(manifestBytes, ledgerTenant, epoch, seq) : null
359
+ if (!manifest) {
360
+ skipped.push(`${label}: manifest is missing or malformed`)
361
+ corrupt = true
362
+ continue
363
+ }
364
+ const bytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'db'))
365
+ if (!bytes) {
366
+ skipped.push(`${label}: image is missing`)
367
+ corrupt = true
368
+ continue
369
+ }
370
+ try {
371
+ verify(bytes, manifest)
372
+ return { point: { manifest, bytes }, skipped }
373
+ } catch (error) {
374
+ if (!(error instanceof RestoreVerificationError)) throw error
375
+ skipped.push(`${label}: ${error.message}`)
376
+ corrupt = true
377
+ }
378
+ }
379
+
380
+ const baseBytes: Uint8Array | null = keys.includes(baseKeyOf(leaseKey, epoch)) ? await durable.get(baseKeyOf(leaseKey, epoch)) : null
381
+ const base: BaseRecord | null = baseBytes ? parseBase(baseBytes, epoch) : null
382
+ if (base?.from === null) break
383
+ if (base?.from) {
384
+ if (base.from.epoch <= forgottenAtEpoch) break
385
+ epoch = base.from.epoch
386
+ ceiling = base.from.seq
387
+ continue
388
+ }
389
+ skipped.push(`e${epoch}: base is missing or malformed`)
390
+ epoch = epochsWithEvidence.find(candidate => candidate < epoch!) ?? null
391
+ ceiling = Number.POSITIVE_INFINITY
392
+ }
393
+ if (corrupt) throw new RestoreVerificationError('durable copies exist but none of them verifies', skipped)
394
+ return { point: null, skipped }
395
+ }
396
+
397
+ export interface RestoreResult {
398
+ readonly ledger: StateLedger
399
+ /** The snapshot this epoch started from, or `null` for an empty ledger. */
400
+ readonly restoredFrom: SnapshotRef | null
401
+ readonly skipped: readonly string[]
402
+ readonly facts: LedgerImageFacts | null
403
+ }
404
+
405
+ function evictCachedLedger(tenantRoot: string, ledgerTenant: string): void {
406
+ if (!ledgerExists(tenantRoot)) return
407
+ try {
408
+ openDb(tenantRoot, ledgerTenant).close()
409
+ } catch {
410
+ // A stale or damaged local file is about to be replaced; failing to open it changes nothing.
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Makes the local ledger of a freshly acquired epoch equal to the newest verified durable copy, pins that
416
+ * copy in this epoch's `base.json`, and opens the ledger. Local files from an earlier tenure are always
417
+ * discarded: only the durable copy is authoritative.
418
+ */
419
+ export async function restoreTenantLedger(context: EpochCopyContext): Promise<RestoreResult> {
420
+ const { key, root: tenantRoot } = checkedContext(context)
421
+ const { durable, lease, ledgerTenant } = context
422
+ lease.assertHeld()
423
+ const resolution = await resolveRestorePoint(
424
+ durable, key, ledgerTenant, lease.epoch, lease.forgottenAtEpoch,
425
+ (bytes, manifest) => verifyCandidate(bytes, manifest, ledgerTenant),
426
+ )
427
+ lease.assertHeld()
428
+
429
+ evictCachedLedger(tenantRoot, ledgerTenant)
430
+ const dbPath = ledgerDbPath(tenantRoot)
431
+ mkdirSync(dirname(dbPath), { recursive: true })
432
+ for (const suffix of ['', '-wal', '-shm', '.restoring']) rmSync(`${dbPath}${suffix}`, { force: true })
433
+ const point = resolution.point
434
+ if (point) {
435
+ writeFileSync(`${dbPath}.restoring`, point.bytes)
436
+ renameSync(`${dbPath}.restoring`, dbPath)
437
+ }
438
+ const restoredFrom: SnapshotRef | null = point ? { epoch: point.manifest.epoch, seq: point.manifest.seq, sha256: point.manifest.sha256 } : null
439
+ await writeBase(context, key, restoredFrom)
440
+ return {
441
+ ledger: openDb(tenantRoot, ledgerTenant),
442
+ restoredFrom,
443
+ skipped: resolution.skipped,
444
+ facts: point ? { events: point.manifest.events, maxEventSeq: point.manifest.maxEventSeq } : null,
445
+ }
446
+ }
447
+
448
+ // ---- forgetting --------------------------------------------------------------------
449
+
450
+ /**
451
+ * Removes the local ledger files (the database and its -wal and -shm) of a tenure that flushed everything. Other files
452
+ * of the tenant's directory, such as a planner's working files, are not the ledger's and stay.
453
+ */
454
+ export function discardLocalLedger(context: EpochCopyContext): void {
455
+ const file = ledgerDbPath(checkedContext(context).root)
456
+ for (const path of [file, `${file}-wal`, `${file}-shm`]) rmSync(path, { force: true })
457
+ }
458
+
459
+ /**
460
+ * Forget means delete (red line 6): the floor goes into the lease record first, so no later restore can
461
+ * read an older copy even if a superseded owner uploads one afterwards; then every durable copy and the
462
+ * local files go; then the lease is handed back. The caller closes the tenant's ledger first.
463
+ */
464
+ export async function forgetTenant(context: EpochCopyContext): Promise<{ readonly deletedObjects: number; readonly floor: number }> {
465
+ const { key, root } = checkedContext(context)
466
+ context.lease.assertHeld()
467
+ const record = await context.lease.markForgotten()
468
+ // Only the epochs at or below the floor that was just raised are the forgotten tenure's. An owner that was paused between
469
+ // raising the floor and deleting wakes up after a successor took over: what the successor wrote is above the floor, and
470
+ // it is not this call's to delete.
471
+ const deletedObjects = await sweepMaskedCopies(context.durable, key, record.forgottenAtEpoch)
472
+ rmSync(root, { recursive: true, force: true })
473
+ await context.lease.release()
474
+ return { deletedObjects, floor: record.forgottenAtEpoch }
475
+ }
476
+
477
+ /**
478
+ * Deletes copies at or below the forgotten floor that a superseded owner uploaded after the forget. Every open of a
479
+ * tenant that was ever forgotten runs it; for one that is never opened again an operator runs it, once the previous
480
+ * owner's lease has certainly run out.
481
+ */
482
+ export async function sweepMaskedCopies(durable: DurableStore, leaseKey: string, forgottenAtEpoch: number): Promise<number> {
483
+ let deleted = 0
484
+ const dead = new Set<number>()
485
+ for (const objectKey of await durable.list(`${leaseKey}/e`)) {
486
+ const epoch = epochOfKey(objectKey)
487
+ if (epoch !== null && epoch <= forgottenAtEpoch) dead.add(epoch)
488
+ }
489
+ for (const epoch of dead) deleted += await durable.deletePrefix(epochPrefixOf(leaseKey, epoch))
490
+ return deleted
491
+ }