@forgezero/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/contracts/foundry.toml +9 -0
  4. package/contracts/src/ColdVault.sol +206 -0
  5. package/contracts/src/DepositFactory.sol +202 -0
  6. package/contracts/src/DepositProxy.sol +72 -0
  7. package/contracts/src/IERC20.sol +7 -0
  8. package/contracts/src/MockTokens.sol +32 -0
  9. package/contracts/src/SafeTransferLib.sol +31 -0
  10. package/contracts/test/Custody.t.sol +361 -0
  11. package/contracts/test/Vectors.t.sol +45 -0
  12. package/dist/audit.d.ts +265 -0
  13. package/dist/audit.js +291 -0
  14. package/dist/backup.d.ts +243 -0
  15. package/dist/backup.js +302 -0
  16. package/dist/calendar.d.ts +136 -0
  17. package/dist/calendar.js +129 -0
  18. package/dist/compliance.d.ts +172 -0
  19. package/dist/compliance.js +168 -0
  20. package/dist/finance/binance.d.ts +27 -0
  21. package/dist/finance/binance.js +452 -0
  22. package/dist/finance/chain-addresses.d.ts +130 -0
  23. package/dist/finance/chain-addresses.js +462 -0
  24. package/dist/finance/chain-deposits.d.ts +193 -0
  25. package/dist/finance/chain-deposits.js +596 -0
  26. package/dist/finance/chain-reconcile.d.ts +112 -0
  27. package/dist/finance/chain-reconcile.js +76 -0
  28. package/dist/finance/chain-withdrawals.d.ts +223 -0
  29. package/dist/finance/chain-withdrawals.js +631 -0
  30. package/dist/finance/chain.d.ts +116 -0
  31. package/dist/finance/chain.js +316 -0
  32. package/dist/finance/commission.d.ts +155 -0
  33. package/dist/finance/commission.js +419 -0
  34. package/dist/finance/custody.d.ts +68 -0
  35. package/dist/finance/custody.js +107 -0
  36. package/dist/finance/derive.d.ts +115 -0
  37. package/dist/finance/derive.js +116 -0
  38. package/dist/finance/discounts.d.ts +98 -0
  39. package/dist/finance/discounts.js +90 -0
  40. package/dist/finance/ledger.d.ts +221 -0
  41. package/dist/finance/ledger.js +308 -0
  42. package/dist/finance/market.d.ts +209 -0
  43. package/dist/finance/market.js +112 -0
  44. package/dist/finance/money.d.ts +118 -0
  45. package/dist/finance/money.js +176 -0
  46. package/dist/finance/rates.d.ts +178 -0
  47. package/dist/finance/rates.js +292 -0
  48. package/dist/finance/storage.d.ts +113 -0
  49. package/dist/finance/storage.js +226 -0
  50. package/dist/finance/tax.d.ts +132 -0
  51. package/dist/finance/tax.js +291 -0
  52. package/dist/finance/transfers.d.ts +153 -0
  53. package/dist/finance/transfers.js +292 -0
  54. package/dist/finance/venues.d.ts +190 -0
  55. package/dist/finance/venues.js +251 -0
  56. package/dist/identity.d.ts +115 -0
  57. package/dist/identity.js +111 -0
  58. package/dist/importers.d.ts +87 -0
  59. package/dist/importers.js +250 -0
  60. package/dist/jobs.d.ts +171 -0
  61. package/dist/jobs.js +250 -0
  62. package/dist/notify-templates.d.ts +11 -0
  63. package/dist/notify-templates.js +254 -0
  64. package/dist/notify.d.ts +172 -0
  65. package/dist/notify.js +122 -0
  66. package/dist/openssh.d.ts +36 -0
  67. package/dist/openssh.js +106 -0
  68. package/dist/otpauth.d.ts +57 -0
  69. package/dist/otpauth.js +223 -0
  70. package/dist/outbox.d.ts +234 -0
  71. package/dist/outbox.js +236 -0
  72. package/dist/passkey.d.ts +120 -0
  73. package/dist/passkey.js +105 -0
  74. package/dist/phrase.d.ts +87 -0
  75. package/dist/phrase.js +87 -0
  76. package/dist/pipeline.d.ts +137 -0
  77. package/dist/pipeline.js +121 -0
  78. package/dist/queue.d.ts +243 -0
  79. package/dist/queue.js +246 -0
  80. package/dist/schema-typebox.d.ts +24 -0
  81. package/dist/schema-typebox.js +201 -0
  82. package/dist/schema.d.ts +134 -0
  83. package/dist/schema.js +169 -0
  84. package/dist/serial.d.ts +54 -0
  85. package/dist/serial.js +40 -0
  86. package/dist/slip10.d.ts +37 -0
  87. package/dist/slip10.js +74 -0
  88. package/dist/snp.d.ts +115 -0
  89. package/dist/snp.js +109 -0
  90. package/dist/ssh-agent.d.ts +70 -0
  91. package/dist/ssh-agent.js +141 -0
  92. package/dist/ssh-cert.d.ts +73 -0
  93. package/dist/ssh-cert.js +111 -0
  94. package/dist/totp.d.ts +104 -0
  95. package/dist/totp.js +143 -0
  96. package/package.json +248 -0
package/dist/audit.js ADDED
@@ -0,0 +1,291 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/serial.ts
10
+ function createKeyedSerial(options = {}) {
11
+ const maxKeys = options.maxKeys ?? 1e4;
12
+ const chains = new Map;
13
+ return {
14
+ run(key, work) {
15
+ const previous = chains.get(key) ?? Promise.resolve();
16
+ const next = previous.then(work, work);
17
+ const settled = next.then(() => {
18
+ return;
19
+ }, () => {
20
+ return;
21
+ });
22
+ chains.set(key, settled);
23
+ settled.then(() => {
24
+ if (chains.get(key) === settled)
25
+ chains.delete(key);
26
+ });
27
+ if (chains.size > maxKeys) {
28
+ console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
29
+ }
30
+ return next;
31
+ },
32
+ size: () => chains.size,
33
+ drain: async () => {
34
+ await Promise.allSettled([...chains.values()]);
35
+ }
36
+ };
37
+ }
38
+
39
+ // src/audit.ts
40
+ import { sha256, timingSafeEqual } from "@forgezero/access/security";
41
+ class AuditChainError extends Error {
42
+ code;
43
+ at;
44
+ constructor(code, message, at) {
45
+ super(message);
46
+ this.code = code;
47
+ this.at = at;
48
+ this.name = "AuditChainError";
49
+ }
50
+ }
51
+ var GENESIS_DIGEST = "0".repeat(64);
52
+ function canonicalise(record) {
53
+ const detail = record.detail ? Object.keys(record.detail).sort().map((key) => `${key}=${String(record.detail[key])}`).join("\x1F") : "";
54
+ return [
55
+ record.sequence,
56
+ record.previousDigest,
57
+ record.atMs,
58
+ record.action,
59
+ record.outcome,
60
+ record.actor ?? "",
61
+ record.realm ?? "",
62
+ record.targetCollection ?? "",
63
+ record.targetKey ?? "",
64
+ record.reason ?? "",
65
+ detail
66
+ ].join("\x1E");
67
+ }
68
+ var hashDigester = (input) => sha256(input);
69
+ var sealedDigester = (key) => {
70
+ return async (input) => {
71
+ const { hmacSha256 } = await import("@forgezero/access/security");
72
+ return hmacSha256(key, input);
73
+ };
74
+ };
75
+ async function appendRecord(store, entry, options = {}) {
76
+ const digester = options.digester ?? hashDigester;
77
+ const previous = await store.tail(entry.realm);
78
+ const unsigned = {
79
+ ...entry,
80
+ sequence: (previous?.sequence ?? 0) + 1,
81
+ atMs: (options.now ?? Date.now)(),
82
+ previousDigest: previous?.digest ?? GENESIS_DIGEST
83
+ };
84
+ const record = { ...unsigned, digest: await digester(canonicalise(unsigned)) };
85
+ await store.append(record);
86
+ return record;
87
+ }
88
+ async function verifyChain(records, options = {}) {
89
+ const digester = options.digester ?? hashDigester;
90
+ if (records.length === 0) {
91
+ return { ok: true, checked: 0 };
92
+ }
93
+ if (options.expectGenesis && records[0].previousDigest !== GENESIS_DIGEST) {
94
+ return {
95
+ ok: false,
96
+ checked: 0,
97
+ brokenAt: records[0].sequence,
98
+ reason: "NOT_GENESIS",
99
+ message: `The chain starts at sequence ${records[0].sequence} without a genesis link — records before it are missing.`
100
+ };
101
+ }
102
+ let previous;
103
+ for (const [index, record] of records.entries()) {
104
+ if (previous) {
105
+ if (record.sequence !== previous.sequence + 1) {
106
+ return {
107
+ ok: false,
108
+ checked: index,
109
+ brokenAt: record.sequence,
110
+ reason: "OUT_OF_ORDER",
111
+ message: `Sequence jumps from ${previous.sequence} to ${record.sequence}; ${record.sequence - previous.sequence - 1} record(s) are missing.`
112
+ };
113
+ }
114
+ if (!timingSafeEqual(record.previousDigest, previous.digest)) {
115
+ return {
116
+ ok: false,
117
+ checked: index,
118
+ brokenAt: record.sequence,
119
+ reason: "BROKEN_LINK",
120
+ message: `Record ${record.sequence} does not follow record ${previous.sequence} — one of them was altered.`
121
+ };
122
+ }
123
+ }
124
+ const expected = await digester(canonicalise(record));
125
+ if (!timingSafeEqual(expected, record.digest)) {
126
+ return {
127
+ ok: false,
128
+ checked: index,
129
+ brokenAt: record.sequence,
130
+ reason: "BAD_DIGEST",
131
+ message: `Record ${record.sequence} does not hash to its own digest — its contents were edited in place.`
132
+ };
133
+ }
134
+ previous = record;
135
+ }
136
+ return { ok: true, checked: records.length };
137
+ }
138
+ async function exportRange(store, args, options = {}) {
139
+ const digester = options.digester ?? hashDigester;
140
+ if (args.toSequence < args.fromSequence) {
141
+ throw new AuditChainError("EMPTY_RANGE", "The range ends before it starts.");
142
+ }
143
+ const records = await store.range(args.realm, args.fromSequence, args.toSequence);
144
+ if (records.length === 0) {
145
+ throw new AuditChainError("EMPTY_RANGE", "No records in that range.");
146
+ }
147
+ const verdict = await verifyChain(records, {
148
+ digester,
149
+ expectGenesis: args.fromSequence <= 1
150
+ });
151
+ if (!verdict.ok) {
152
+ throw new AuditChainError(verdict.reason, verdict.message, verdict.brokenAt);
153
+ }
154
+ return {
155
+ realm: args.realm,
156
+ fromSequence: records[0].sequence,
157
+ toSequence: records.at(-1).sequence,
158
+ anchorDigest: records[0].previousDigest,
159
+ records,
160
+ sealDigest: await digester(records.map((record) => record.digest).join("\x1E")),
161
+ exportedAtMs: (options.now ?? Date.now)()
162
+ };
163
+ }
164
+ async function verifyExport(slice, options = {}) {
165
+ const digester = options.digester ?? hashDigester;
166
+ const verdict = await verifyChain(slice.records, {
167
+ digester,
168
+ expectGenesis: slice.anchorDigest === GENESIS_DIGEST
169
+ });
170
+ if (!verdict.ok)
171
+ return verdict;
172
+ if (slice.records[0].previousDigest !== slice.anchorDigest) {
173
+ return {
174
+ ok: false,
175
+ checked: 0,
176
+ brokenAt: slice.fromSequence,
177
+ reason: "BROKEN_LINK",
178
+ message: "The slice does not hang from the anchor it claims."
179
+ };
180
+ }
181
+ const seal = await digester(slice.records.map((record) => record.digest).join("\x1E"));
182
+ if (!timingSafeEqual(seal, slice.sealDigest)) {
183
+ return {
184
+ ok: false,
185
+ checked: slice.records.length,
186
+ brokenAt: slice.fromSequence,
187
+ reason: "BAD_DIGEST",
188
+ message: "The slice does not match its own seal — records were added or removed after export."
189
+ };
190
+ }
191
+ if (options.expectSealDigest && !timingSafeEqual(seal, options.expectSealDigest)) {
192
+ return {
193
+ ok: false,
194
+ checked: slice.records.length,
195
+ brokenAt: slice.fromSequence,
196
+ reason: "BAD_DIGEST",
197
+ message: "The slice does not match the seal that was published at the time — history was rewritten after it."
198
+ };
199
+ }
200
+ return verdict;
201
+ }
202
+ function createAuditChain(options) {
203
+ const digester = options.digester ?? hashDigester;
204
+ const serial = createKeyedSerial();
205
+ const enqueue = (realm, run) => serial.run(realm ?? "\x00platform", run);
206
+ return {
207
+ append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
208
+ verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
209
+ const records = await options.store.range(realm, fromSequence, toSequence);
210
+ return verifyChain(records, { digester, expectGenesis: fromSequence <= 1 });
211
+ },
212
+ export: (args) => exportRange(options.store, args, { ...options, digester }),
213
+ sink: {
214
+ write(record) {
215
+ const entry = {
216
+ action: `route.${record.route}`,
217
+ outcome: record.outcome,
218
+ actor: record.userKey,
219
+ realm: record.realm,
220
+ targetKey: record.targetKey,
221
+ detail: {
222
+ method: record.method,
223
+ status: record.status,
224
+ ...record.code ? { code: record.code } : {},
225
+ ...flattenDetail(record.detail)
226
+ },
227
+ ...record.outcome === "denied" && record.code ? { reason: record.code } : {}
228
+ };
229
+ enqueue(record.realm, () => appendRecord(options.store, entry, { ...options, digester })).catch((error) => options.onError?.(error, entry));
230
+ }
231
+ }
232
+ };
233
+ }
234
+ function flattenDetail(detail) {
235
+ if (!detail)
236
+ return {};
237
+ const flat = {};
238
+ for (const [key, value] of Object.entries(detail)) {
239
+ if (value === null || value === undefined)
240
+ flat[key] = null;
241
+ else if (typeof value === "object")
242
+ flat[key] = JSON.stringify(value);
243
+ else if (typeof value === "bigint")
244
+ flat[key] = value.toString();
245
+ else
246
+ flat[key] = value;
247
+ }
248
+ return flat;
249
+ }
250
+ function memoryStore() {
251
+ const chains = new Map;
252
+ const keyOf = (realm) => realm ?? "\x00platform";
253
+ return {
254
+ async tail(realm) {
255
+ return chains.get(keyOf(realm))?.at(-1) ?? null;
256
+ },
257
+ async append(record) {
258
+ const key = keyOf(record.realm);
259
+ const chain = chains.get(key) ?? [];
260
+ if (chain.some((existing) => existing.sequence === record.sequence)) {
261
+ throw new AuditChainError("OUT_OF_ORDER", `Sequence ${record.sequence} already exists in this realm.`, record.sequence);
262
+ }
263
+ chain.push(record);
264
+ chains.set(key, chain);
265
+ },
266
+ async range(realm, fromSequence, toSequence) {
267
+ return (chains.get(keyOf(realm)) ?? []).filter((record) => record.sequence >= fromSequence && record.sequence <= toSequence).sort((a, b) => a.sequence - b.sequence);
268
+ },
269
+ all(realm) {
270
+ return [...chains.get(keyOf(realm)) ?? []];
271
+ },
272
+ clear() {
273
+ chains.clear();
274
+ }
275
+ };
276
+ }
277
+ var VERSION = "0.1.0";
278
+ export {
279
+ verifyExport,
280
+ verifyChain,
281
+ sealedDigester,
282
+ memoryStore,
283
+ hashDigester,
284
+ exportRange,
285
+ createAuditChain,
286
+ canonicalise,
287
+ appendRecord,
288
+ VERSION,
289
+ GENESIS_DIGEST,
290
+ AuditChainError
291
+ };
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Encrypted snapshots, and the restore that reads them back.
3
+ *
4
+ * A backup nobody has restored is a hypothesis. The two halves are one module
5
+ * precisely so the restore is exercised by the same test that writes the
6
+ * snapshot — the alternative is a backup job that has run nightly for a year
7
+ * and a restore path that has never been executed, which is the configuration
8
+ * everybody discovers on the worst possible day.
9
+ *
10
+ * ## Sealed before it leaves the machine
11
+ *
12
+ * Every chunk is encrypted under a key derived from the realm master seed, with
13
+ * AAD binding the realm, the snapshot and the chunk index. The object store —
14
+ * ours or the tenant's — holds ciphertext it cannot read, which is what makes
15
+ * "point it at your own bucket" a real offer rather than a compliance-flavoured
16
+ * one. It also means a snapshot lifted from realm A fails its GCM tag when
17
+ * restored into realm B rather than quietly loading someone else's data.
18
+ *
19
+ * ## Chunked, and each chunk verified
20
+ *
21
+ * A snapshot is written in chunks with a digest per chunk in the manifest. A
22
+ * truncated upload — the ordinary outcome of a network failure mid-transfer —
23
+ * otherwise produces an object that exists, has a plausible size, and restores
24
+ * into a silently incomplete database. Verification catches it while there is
25
+ * still a good copy to fall back on.
26
+ *
27
+ * ## Restore refuses more than it accepts
28
+ *
29
+ * A failed restore is recoverable; a corrupt one is not, because it overwrites
30
+ * the thing it was supposed to save. Every check therefore fails closed: a
31
+ * missing chunk, a digest mismatch, a realm mismatch and an unknown format all
32
+ * refuse rather than doing their best.
33
+ */
34
+ export declare class BackupError extends Error {
35
+ readonly code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY';
36
+ constructor(code: 'CHUNK_MISSING' | 'DIGEST_MISMATCH' | 'REALM_MISMATCH' | 'BAD_MANIFEST' | 'UNSUPPORTED_FORMAT' | 'DECRYPT_FAILED' | 'EMPTY_SNAPSHOT' | 'RETENTION_WOULD_EMPTY', message: string);
37
+ }
38
+ /** Bumped only for a change that an older reader cannot handle. */
39
+ export declare const SNAPSHOT_FORMAT = 1;
40
+ export interface ChunkRecord {
41
+ index: number;
42
+ /** Object key in the store. */
43
+ object: string;
44
+ /** SHA-256 of the PLAINTEXT, so a restore verifies what it actually recovered. */
45
+ digest: string;
46
+ rows: number;
47
+ bytes: number;
48
+ }
49
+ export interface SnapshotManifest {
50
+ format: number;
51
+ id: string;
52
+ realm: string;
53
+ createdAtMs: number;
54
+ /** Which collections, and how many rows from each. */
55
+ collections: Record<string, number>;
56
+ chunks: readonly ChunkRecord[];
57
+ totalRows: number;
58
+ /** Digest over every chunk digest — one value that fixes the whole snapshot. */
59
+ sealDigest: string;
60
+ /** Free-form: schema version, application version, who triggered it. */
61
+ labels?: Record<string, string>;
62
+ }
63
+ /** The subset of an object store this needs. `@forgezero/providers/storage` satisfies it. */
64
+ export interface ObjectStore {
65
+ putObject(args: {
66
+ key: string;
67
+ body: Uint8Array | string;
68
+ contentType?: string;
69
+ }): Promise<unknown>;
70
+ getObject(key: string): Promise<Uint8Array>;
71
+ listObjects(prefix?: string, limit?: number): Promise<{
72
+ key: string;
73
+ size: number;
74
+ }[]>;
75
+ deleteObject(key: string): Promise<void>;
76
+ }
77
+ /** Where the rows come from. Async-iterable so a large collection never lands in memory whole. */
78
+ export interface RowSource {
79
+ collections(): Promise<string[]>;
80
+ rows(collection: string): AsyncIterable<Record<string, unknown>>;
81
+ }
82
+ /** Where they go on the way back. */
83
+ export interface RowSink {
84
+ /**
85
+ * Called once per chunk with the rows it held.
86
+ *
87
+ * Per chunk rather than per row so a sink can use a bulk import, and so a
88
+ * failure names a bounded set of rows rather than one.
89
+ */
90
+ write(collection: string, rows: Record<string, unknown>[]): Promise<void>;
91
+ /** Called before anything is written. The sink's chance to refuse or truncate. */
92
+ begin?(manifest: SnapshotManifest): Promise<void>;
93
+ finish?(manifest: SnapshotManifest): Promise<void>;
94
+ }
95
+ export interface SnapshotOptions {
96
+ store: ObjectStore;
97
+ source: RowSource;
98
+ realm: string;
99
+ masterSeed: Uint8Array;
100
+ /** Rows per chunk. Smaller means more objects; larger means more memory. */
101
+ chunkRows?: number;
102
+ prefix?: string;
103
+ labels?: Record<string, string>;
104
+ id?: string;
105
+ now?: () => number;
106
+ onProgress?: (progress: {
107
+ collection: string;
108
+ rows: number;
109
+ chunks: number;
110
+ }) => void;
111
+ }
112
+ /**
113
+ * Write a snapshot.
114
+ *
115
+ * The manifest is written **last**, deliberately. Its presence is what marks a
116
+ * snapshot complete, so a run that dies halfway leaves orphaned chunks and no
117
+ * manifest — which `listSnapshots` skips and `prune` can collect. The opposite
118
+ * order produces a manifest advertising chunks that were never uploaded, and
119
+ * that snapshot looks valid until somebody needs it.
120
+ */
121
+ export declare function snapshot(options: SnapshotOptions): Promise<SnapshotManifest>;
122
+ export interface VerifyReport {
123
+ ok: boolean;
124
+ id: string;
125
+ chunksChecked: number;
126
+ rowsChecked: number;
127
+ problems: {
128
+ chunk: number;
129
+ code: BackupError['code'];
130
+ message: string;
131
+ }[];
132
+ }
133
+ /**
134
+ * Read every chunk, decrypt it, and check it against the manifest.
135
+ *
136
+ * Reports EVERY problem rather than the first, which is the opposite of the
137
+ * audit chain's rule and correct for the same underlying reason: chunks are
138
+ * independent, so a second failure is genuinely new information, whereas a
139
+ * second broken chain link is only a consequence of the first. Knowing whether
140
+ * one chunk or forty are bad decides whether the snapshot is repairable.
141
+ */
142
+ export declare function verifySnapshot(args: {
143
+ store: ObjectStore;
144
+ manifest: SnapshotManifest;
145
+ masterSeed: Uint8Array;
146
+ }): Promise<VerifyReport>;
147
+ export interface RestoreOptions {
148
+ store: ObjectStore;
149
+ sink: RowSink;
150
+ manifest: SnapshotManifest;
151
+ masterSeed: Uint8Array;
152
+ /** The realm being restored INTO. Must match the manifest. */
153
+ realm: string;
154
+ /**
155
+ * Skip the verify pass.
156
+ *
157
+ * Exists for a restore under time pressure on a snapshot verified an hour
158
+ * ago. Off by default: writing unverified data over a broken database is how
159
+ * a recoverable outage becomes data loss.
160
+ */
161
+ skipVerify?: boolean;
162
+ onProgress?: (progress: {
163
+ chunk: number;
164
+ of: number;
165
+ rows: number;
166
+ }) => void;
167
+ }
168
+ export interface RestoreReport {
169
+ id: string;
170
+ rowsRestored: number;
171
+ collections: Record<string, number>;
172
+ }
173
+ export declare function restore(options: RestoreOptions): Promise<RestoreReport>;
174
+ export declare function listSnapshots(args: {
175
+ store: ObjectStore;
176
+ realm: string;
177
+ prefix?: string;
178
+ }): Promise<SnapshotManifest[]>;
179
+ export interface RetentionPolicy {
180
+ /** Most recent N, regardless of age. */
181
+ keepLast: number;
182
+ keepDaily?: number;
183
+ keepWeekly?: number;
184
+ keepMonthly?: number;
185
+ }
186
+ export declare const DEFAULT_RETENTION: RetentionPolicy;
187
+ /**
188
+ * Decide what to keep.
189
+ *
190
+ * Grandfather-father-son: the newest few unconditionally, then one per day, per
191
+ * week and per month. The alternative — "delete anything older than N days" —
192
+ * loses the ability to recover from a corruption that was introduced before the
193
+ * window and only noticed afterwards, which is the common case for corruption.
194
+ *
195
+ * The **newest snapshot is never selected for deletion**, whatever the policy
196
+ * says. A retention rule that can empty the bucket is a data-loss bug waiting
197
+ * for a clock skew or a misconfigured `keepLast: 0`.
198
+ */
199
+ export declare function selectForDeletion(manifests: readonly SnapshotManifest[], policy?: RetentionPolicy): SnapshotManifest[];
200
+ export declare function prune(args: {
201
+ store: ObjectStore;
202
+ realm: string;
203
+ policy?: RetentionPolicy;
204
+ prefix?: string;
205
+ /** Report what would go without removing it. */
206
+ dryRun?: boolean;
207
+ }): Promise<{
208
+ deleted: string[];
209
+ kept: number;
210
+ }>;
211
+ /**
212
+ * The nightly snapshot, as a job spec.
213
+ *
214
+ * Verification runs immediately after the write, on purpose. Verifying a
215
+ * snapshot a week later tells you it was broken a week ago; verifying it now
216
+ * tells you while the source data still exists to take another one.
217
+ */
218
+ export declare function backupJob(options: SnapshotOptions & {
219
+ policy?: RetentionPolicy;
220
+ key?: string;
221
+ everyMs?: number;
222
+ }): {
223
+ key: string;
224
+ everyMs: number;
225
+ run: () => Promise<{
226
+ ok: boolean;
227
+ detail: {
228
+ id: string;
229
+ problems: number;
230
+ rows?: undefined;
231
+ pruned?: undefined;
232
+ };
233
+ } | {
234
+ ok: boolean;
235
+ detail: {
236
+ id: string;
237
+ rows: number;
238
+ pruned: number;
239
+ problems?: undefined;
240
+ };
241
+ }>;
242
+ };
243
+ export declare const VERSION = "0.1.0";