a2a-exposed 0.4.1 → 0.6.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 (47) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +62 -12
  4. package/lib/deploy.mjs +42 -19
  5. package/lib/main.mjs +32 -11
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +51 -7
  12. package/worker/deploy.env.example +28 -1
  13. package/worker/migrations/0006_mcp.sql +43 -0
  14. package/worker/migrations/0007_cimd.sql +10 -0
  15. package/worker/migrations/0008_oidc_txns.sql +12 -0
  16. package/worker/package.json +6 -2
  17. package/worker/src/approval-oidc.ts +247 -0
  18. package/worker/src/backup.ts +186 -0
  19. package/worker/src/directory.ts +120 -0
  20. package/worker/src/export.ts +156 -0
  21. package/worker/src/hosted.ts +403 -0
  22. package/worker/src/index.ts +1327 -446
  23. package/worker/src/mcp.ts +336 -0
  24. package/worker/src/migrations.ts +22 -0
  25. package/worker/src/pairing.ts +18 -5
  26. package/worker/src/sql-raw.d.ts +5 -0
  27. package/worker/src/storage.ts +100 -0
  28. package/worker/src/tenancy.ts +450 -0
  29. package/worker/src/wake.ts +14 -2
  30. package/worker/test/alarms.test.ts +231 -0
  31. package/worker/test/approval-oidc.test.ts +331 -0
  32. package/worker/test/bridge-thread.mjs +40 -0
  33. package/worker/test/d1.ts +146 -2
  34. package/worker/test/directory.test.ts +90 -0
  35. package/worker/test/export.test.ts +544 -0
  36. package/worker/test/facade.test.ts +1 -1
  37. package/worker/test/harness-worker.ts +80 -0
  38. package/worker/test/hosted.test.ts +221 -0
  39. package/worker/test/mcp.test.ts +697 -0
  40. package/worker/test/migrations.test.ts +5 -5
  41. package/worker/test/pairing.test.ts +20 -7
  42. package/worker/test/register-hooks.mjs +4 -0
  43. package/worker/test/sql-raw-hook.mjs +22 -0
  44. package/worker/test/storage.test.ts +74 -0
  45. package/worker/test/tenancy.test.ts +217 -0
  46. package/worker/test/wake-retry.test.ts +74 -0
  47. package/worker/test/wake.test.ts +18 -0
@@ -0,0 +1,186 @@
1
+ // Daily SQL snapshots. The R2 binding and the `0 3 * * *` trigger exist only when the deploy sets
2
+ // A2A_BACKUP_BUCKET=1. With that unset there is no bucket and this module is not called.
3
+ // Objects are `tenants/<id>/<YYYY-MM-DD>.sql`. A dated object is removed when its date is 30 or more
4
+ // days before the run (UTC), which keeps 30 daily snapshots.
5
+
6
+ import { exportRows, toSql, ExportError } from "./export.ts";
7
+ import { parseDirectoryEntry, pinDirectoryRegion, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
8
+ import { namespaceForRegion, parseGates, type DoNamespace, type KvNamespace, type SqlDb, type WorkerBindings } from "./tenancy.ts";
9
+
10
+ export const DAILY_CRON = "0 3 * * *";
11
+ export const RETENTION_DAYS = 30;
12
+ /** Self-host has one inbox, so its objects live under this id. */
13
+ export const SELF_TENANT_ID = "self";
14
+
15
+ const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ export type R2BucketLike = NonNullable<WorkerBindings["BACKUP_BUCKET"]>;
19
+
20
+ export interface BackupWritten {
21
+ tenantId: string;
22
+ key: string;
23
+ deleted: string[];
24
+ }
25
+
26
+ export interface BackupReport {
27
+ written: BackupWritten[];
28
+ skipped: { tenantId: string; status: string }[];
29
+ failed: { tenantId: string; error: string }[];
30
+ }
31
+
32
+ export function dayUtc(now: Date): string {
33
+ return now.toISOString().slice(0, 10);
34
+ }
35
+
36
+ /** UTC midnight for a real calendar day, or null. */
37
+ export function parseDay(day: string): number | null {
38
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
39
+ if (!m) return null;
40
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
41
+ const t = Date.UTC(y, mo - 1, d);
42
+ const dt = new Date(t);
43
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
44
+ return t;
45
+ }
46
+
47
+ /** True when `fileDay` is at least {@link RETENTION_DAYS} before `today`. */
48
+ export function olderThanRetention(fileDay: string, today: string): boolean {
49
+ const file = parseDay(fileDay);
50
+ const now = parseDay(today);
51
+ if (file === null || now === null) return false;
52
+ return now - file >= RETENTION_DAYS * DAY_MS;
53
+ }
54
+
55
+ export function snapshotKey(tenantId: string, day: string): string {
56
+ if (!TENANT_ID.test(tenantId)) throw new ExportError("backup: tenant id is invalid");
57
+ if (parseDay(day) === null) throw new ExportError("backup: day is invalid");
58
+ return `tenants/${tenantId}/${day}.sql`;
59
+ }
60
+
61
+ /** The `YYYY-MM-DD` in `tenants/<id>/<day>.sql`, or null when the key is not one of ours. */
62
+ export function objectDay(key: string, tenantId: string): string | null {
63
+ const prefix = `tenants/${tenantId}/`;
64
+ if (!key.startsWith(prefix) || !key.endsWith(".sql")) return null;
65
+ const day = key.slice(prefix.length, -".sql".length);
66
+ return parseDay(day) === null ? null : day;
67
+ }
68
+
69
+ async function listAll(bucket: R2BucketLike, prefix: string): Promise<string[]> {
70
+ const keys: string[] = [];
71
+ let cursor: string | undefined;
72
+ for (;;) {
73
+ const res = await bucket.list({ prefix, cursor, limit: 1000 });
74
+ for (const obj of res.objects ?? []) keys.push(obj.key);
75
+ if (!res.truncated) return keys;
76
+ if (!res.cursor || res.cursor === cursor) throw new ExportError("backup: snapshot listing did not finish");
77
+ cursor = res.cursor;
78
+ }
79
+ }
80
+
81
+ /** Delete dated objects for this tenant that are past retention. The object written today stays. */
82
+ export async function pruneSnapshots(bucket: R2BucketLike, tenantId: string, today: string): Promise<string[]> {
83
+ if (!TENANT_ID.test(tenantId)) throw new ExportError("backup: tenant id is invalid");
84
+ const doomed = (await listAll(bucket, `tenants/${tenantId}/`)).filter((key) => {
85
+ const day = objectDay(key, tenantId);
86
+ return day !== null && olderThanRetention(day, today);
87
+ });
88
+ for (let i = 0; i < doomed.length; i += 1000) await bucket.delete(doomed.slice(i, i + 1000));
89
+ return doomed;
90
+ }
91
+
92
+ export async function writeSnapshot(bucket: R2BucketLike, tenantId: string, sql: string, now = new Date()): Promise<BackupWritten> {
93
+ const day = dayUtc(now);
94
+ const key = snapshotKey(tenantId, day);
95
+ await bucket.put(key, sql, { httpMetadata: { contentType: "application/sql" } });
96
+ const deleted = await pruneSnapshots(bucket, tenantId, day);
97
+ return { tenantId, key, deleted };
98
+ }
99
+
100
+ /** One self-host inbox, under {@link SELF_TENANT_ID}. */
101
+ export async function backupDatabase(bucket: R2BucketLike, db: SqlDb, tenantId = SELF_TENANT_ID, now = new Date()): Promise<BackupWritten> {
102
+ const file = await exportRows(db, now);
103
+ return writeSnapshot(bucket, tenantId, toSql(file), now);
104
+ }
105
+
106
+ interface KvLister extends KvNamespace {
107
+ list?(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
108
+ keys: { name: string }[];
109
+ list_complete: boolean;
110
+ cursor?: string;
111
+ }>;
112
+ }
113
+
114
+ async function directoryNames(kv: KvLister): Promise<string[]> {
115
+ if (typeof kv.list !== "function") throw new ExportError("backup: directory cannot be listed");
116
+ const names: string[] = [];
117
+ let cursor: string | undefined;
118
+ for (;;) {
119
+ const res = await kv.list({ prefix: "tenant:", cursor, limit: 1000 });
120
+ for (const k of res.keys ?? []) {
121
+ const name = k.name.startsWith("tenant:") ? k.name.slice("tenant:".length) : "";
122
+ if (TENANT_NAME.test(name)) names.push(name);
123
+ }
124
+ if (res.list_complete !== false) return names;
125
+ if (!res.cursor || res.cursor === cursor) throw new ExportError("backup: directory listing did not finish");
126
+ cursor = res.cursor;
127
+ }
128
+ }
129
+
130
+ function clip(e: unknown): string {
131
+ const msg = e instanceof Error ? e.message : String(e);
132
+ return msg.slice(0, 200);
133
+ }
134
+
135
+ /**
136
+ * Fan-out for a hosted deploy. Active tenants are exported; other statuses are skipped.
137
+ * An entry with no region yet is pinned the same way a request would pin it, then read.
138
+ * One tenant's failure is recorded and the rest still run.
139
+ */
140
+ export async function backupDirectory(
141
+ kv: KvLister,
142
+ bucket: R2BucketLike,
143
+ ns: DoNamespace,
144
+ dataRegion: string,
145
+ now = new Date(),
146
+ ): Promise<BackupReport> {
147
+ const report: BackupReport = { written: [], skipped: [], failed: [] };
148
+ const names = await directoryNames(kv);
149
+ for (const name of names) {
150
+ let entry: DirectoryEntry | null = null;
151
+ try {
152
+ const raw = await kv.get(`tenant:${name}`, "json");
153
+ entry = parseDirectoryEntry(raw);
154
+ if (!entry) throw new ExportError("backup: directory entry is unreadable");
155
+ const pinned = await pinDirectoryRegion(kv, name, entry, dataRegion);
156
+ if (!pinned) throw new ExportError("backup: directory unavailable");
157
+ entry = pinned;
158
+ if (entry.status !== "active") {
159
+ report.skipped.push({ tenantId: entry.id, status: entry.status });
160
+ continue;
161
+ }
162
+ const sql = await exportTenantSql(ns, entry);
163
+ report.written.push(await writeSnapshot(bucket, entry.id, sql.sql, now));
164
+ } catch (e) {
165
+ report.failed.push({ tenantId: entry?.id || name, error: clip(e) });
166
+ }
167
+ }
168
+ return report;
169
+ }
170
+
171
+ export async function exportTenantSql(ns: DoNamespace, entry: DirectoryEntry): Promise<{ sql: string; exportedAt: string }> {
172
+ const bound = namespaceForRegion(ns, tenantRegion(entry));
173
+ const stub = bound.get(bound.idFromName(entry.id)) as { exportSql?: () => Promise<{ sql: string; exportedAt: string }> };
174
+ if (typeof stub.exportSql !== "function") throw new ExportError("exportSql: not available");
175
+ const out = await stub.exportSql();
176
+ if (!out || typeof out.sql !== "string" || typeof out.exportedAt !== "string") throw new ExportError("exportSql: empty");
177
+ return out;
178
+ }
179
+
180
+ /** Hosted daily job. Returns null when this invocation is not the backup cron or the bucket is unset. */
181
+ export async function hostedDailyBackup(controller: { cron?: string }, env: WorkerBindings, now = new Date()): Promise<BackupReport | null> {
182
+ if (controller.cron !== DAILY_CRON || !env.BACKUP_BUCKET) return null;
183
+ if (env.TENANCY !== "host" || !env.TENANT_DO) return null;
184
+ if (!env.TENANT_DIRECTORY) throw new ExportError("backup: directory cannot be listed");
185
+ return backupDirectory(env.TENANT_DIRECTORY, env.BACKUP_BUCKET, env.TENANT_DO, parseGates(env).gates.dataRegion, now);
186
+ }
@@ -0,0 +1,120 @@
1
+ // Host → tenant name directory. The hosted router reads this before it touches a Durable Object.
2
+ // Control (later) writes `tenant:<name>` = { id, status, region, version }. No secrets live here.
3
+ // An entry with an empty region is pinned on first sight (`eu`, `fedramp`, or `default`) and then only read.
4
+ // The isolate caches each lookup, including "not in the directory", for 60 seconds, capped so a scan of
5
+ // random names cannot grow it without bound.
6
+
7
+ import type { KvNamespace } from "./tenancy.ts";
8
+
9
+ /** 4–32 characters: letter or digit, then letters, digits or hyphens, ending in a letter or digit. */
10
+ export const TENANT_NAME = /^[a-z0-9](?:[a-z0-9-]{2,30}[a-z0-9])$/;
11
+
12
+ export const DIRECTORY_TTL_MS = 60_000;
13
+ /** Isolate cap. A scan of random names must not keep a map entry for every miss. */
14
+ export const DIRECTORY_MAX = 1024;
15
+
16
+ export interface DirectoryEntry {
17
+ id: string;
18
+ status: string;
19
+ /**
20
+ * Placement, pinned once. `eu` and `fedramp` select that jurisdiction. `default` is the namespace's
21
+ * default placement. `""` means not pinned yet.
22
+ */
23
+ region: string;
24
+ version: number;
25
+ }
26
+
27
+ const cache = new Map<string, { at: number; entry: DirectoryEntry | null }>();
28
+
29
+ export function clearDirectoryCache(): void {
30
+ cache.clear();
31
+ }
32
+
33
+ /** Move `name` to the newest end and drop expired, then oldest, entries past the cap. */
34
+ function remember(name: string, at: number, entry: DirectoryEntry | null, now: number): void {
35
+ cache.delete(name);
36
+ cache.set(name, { at, entry });
37
+ if (cache.size <= DIRECTORY_MAX) return;
38
+ for (const [k, v] of cache) if (now - v.at >= DIRECTORY_TTL_MS) cache.delete(k);
39
+ while (cache.size > DIRECTORY_MAX) {
40
+ const oldest = cache.keys().next().value;
41
+ if (oldest === undefined) break;
42
+ cache.delete(oldest);
43
+ }
44
+ }
45
+
46
+ export function directoryKey(name: string): string {
47
+ return `tenant:${name}`;
48
+ }
49
+
50
+ /**
51
+ * First label under `domain`, or null. The apex, a nested name (`a.b.domain`) and a different parent do not match.
52
+ * `domain` is compared already normalised (lowercase, no surrounding dots).
53
+ */
54
+ export function tenantNameFromHost(host: string, domain: string): string | null {
55
+ const h = host.toLowerCase();
56
+ const d = domain.toLowerCase();
57
+ if (!d || h === d || !h.endsWith("." + d)) return null;
58
+ const name = h.slice(0, -(d.length + 1));
59
+ return TENANT_NAME.test(name) ? name : null;
60
+ }
61
+
62
+ /** `eu` or `fedramp` when the directory entry says so. `default` and anything else use the default namespace. */
63
+ export function tenantRegion(entry: DirectoryEntry): string {
64
+ return entry.region === "eu" || entry.region === "fedramp" ? entry.region : "";
65
+ }
66
+
67
+ /**
68
+ * Write a placement into an entry that does not have one yet, then return that entry.
69
+ * `DATA_REGION` is read only here. A later change to it does not move a tenant whose row already
70
+ * has `eu`, `fedramp`, or `default`. Returns null when the write fails; the caller must not address
71
+ * an object until the placement is stored.
72
+ */
73
+ export async function pinDirectoryRegion(
74
+ kv: Pick<KvNamespace, "put">,
75
+ name: string,
76
+ entry: DirectoryEntry,
77
+ dataRegion: string,
78
+ now = Date.now(),
79
+ ): Promise<DirectoryEntry | null> {
80
+ if (entry.region !== "") return entry;
81
+ const region = dataRegion === "eu" || dataRegion === "fedramp" ? dataRegion : "default";
82
+ const pinned: DirectoryEntry = { ...entry, region };
83
+ try {
84
+ await kv.put(directoryKey(name), JSON.stringify(pinned));
85
+ } catch {
86
+ return null;
87
+ }
88
+ remember(name, now, pinned, now);
89
+ return pinned;
90
+ }
91
+
92
+ export function parseDirectoryEntry(raw: unknown): DirectoryEntry | null {
93
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
94
+ const o = raw as Record<string, unknown>;
95
+ if (typeof o.id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(o.id)) return null;
96
+ if (typeof o.status !== "string" || !/^[a-z]{1,32}$/.test(o.status)) return null;
97
+ const region = typeof o.region === "string" ? o.region : "";
98
+ const version = typeof o.version === "number" && Number.isSafeInteger(o.version) ? o.version : 0;
99
+ return { id: o.id, status: o.status, region, version };
100
+ }
101
+
102
+ /**
103
+ * Cached directory lookup. A missing or unreadable value is cached as "unknown" so a scan of random names
104
+ * does not hit KV on every request. Pass `now` from tests.
105
+ */
106
+ export async function lookupDirectory(kv: Pick<KvNamespace, "get">, name: string, now = Date.now()): Promise<DirectoryEntry | null> {
107
+ const hit = cache.get(name);
108
+ if (hit && now - hit.at < DIRECTORY_TTL_MS) {
109
+ remember(name, hit.at, hit.entry, now);
110
+ return hit.entry;
111
+ }
112
+ let entry: DirectoryEntry | null = null;
113
+ try {
114
+ entry = parseDirectoryEntry(await kv.get(directoryKey(name), "json"));
115
+ } catch {
116
+ entry = null;
117
+ }
118
+ remember(name, now, entry, now);
119
+ return entry;
120
+ }
@@ -0,0 +1,156 @@
1
+ // Inbox export. Both the owner HTTP API and the hosted Durable Object use this.
2
+ // The allowlist is the application tables. `d1_migrations` and the hosted `tenant_config` row (it holds
3
+ // `secrets_enc`) are left out. The SQL text is a dump the operator already owns.
4
+
5
+ import type { SqlDb } from "./tenancy.ts";
6
+
7
+ export class ExportError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "ExportError";
11
+ }
12
+ }
13
+
14
+ /** Application tables, in a stable order. Schema changes add a table here in the same change as the migration. */
15
+ export const EXPORT_TABLES = [
16
+ "peers", "tasks", "messages", "history", "outbound", "wakes",
17
+ "rate", "wake_budget", "device_requests", "pairing_rate", "settings",
18
+ "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants",
19
+ "outbound_peers", "oauth_client_metadata", "oidc_txns",
20
+ ] as const;
21
+
22
+ export type ExportTable = (typeof EXPORT_TABLES)[number];
23
+
24
+ export interface ExportFile {
25
+ format: "a2a-exposed-export";
26
+ version: 1;
27
+ exportedAt: string;
28
+ tables: { [K in ExportTable]?: Record<string, unknown>[] };
29
+ }
30
+
31
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ function quoteIdent(name: string): string {
34
+ if (!IDENT.test(name)) throw new ExportError("export: bad column");
35
+ return `"${name}"`;
36
+ }
37
+
38
+ function cell(v: unknown): unknown {
39
+ if (v === null || typeof v === "string" || typeof v === "boolean") return v;
40
+ if (typeof v === "number") {
41
+ if (!Number.isFinite(v)) throw new ExportError("export: non-finite number");
42
+ return v;
43
+ }
44
+ if (typeof v === "bigint") {
45
+ if (v > BigInt(Number.MAX_SAFE_INTEGER) || v < BigInt(Number.MIN_SAFE_INTEGER)) throw new ExportError("export: integer out of range");
46
+ return Number(v);
47
+ }
48
+ throw new ExportError("export: unsupported value");
49
+ }
50
+
51
+ function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
52
+ const out: Record<string, unknown> = {};
53
+ for (const [k, v] of Object.entries(row)) {
54
+ if (!IDENT.test(k)) throw new ExportError("export: bad column");
55
+ out[k] = cell(v);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** A SQL string literal. SQLite rejects a script that contains a NUL byte, so U+0000 is `char(0)` instead of a raw byte. */
61
+ function sqlString(v: string): string {
62
+ const parts = v.split("\u0000");
63
+ if (parts.length === 1) return `'${v.replaceAll("'", "''")}'`;
64
+ const bits: string[] = [];
65
+ for (let i = 0; i < parts.length; i++) {
66
+ if (parts[i]) bits.push(`'${parts[i].replaceAll("'", "''")}'`);
67
+ if (i < parts.length - 1) bits.push("char(0)");
68
+ }
69
+ return bits.length === 1 ? bits[0] : `(${bits.join(" || ")})`;
70
+ }
71
+
72
+ function sqlLit(v: unknown): string {
73
+ if (v === null) return "NULL";
74
+ if (typeof v === "boolean") return v ? "1" : "0";
75
+ if (typeof v === "number") return Number.isInteger(v) ? String(v) : JSON.stringify(v);
76
+ if (typeof v === "string") return sqlString(v);
77
+ throw new ExportError("export: unsupported value");
78
+ }
79
+
80
+ /**
81
+ * Every allowlisted table, including ones with no rows.
82
+ * One `batch` is one transaction. D1 runs the batch as a single transaction, so a concurrent write cannot
83
+ * land between tables. The Durable Object adapter runs that batch inside `transactionSync`, which blocks
84
+ * other requests for the whole read. Do not split this back into per-table queries.
85
+ */
86
+ export async function exportRows(db: SqlDb, now = new Date()): Promise<ExportFile> {
87
+ const out = await db.batch(EXPORT_TABLES.map((name) => db.prepare(`SELECT * FROM ${quoteIdent(name)}`)));
88
+ const tables: ExportFile["tables"] = {};
89
+ EXPORT_TABLES.forEach((name, i) => {
90
+ tables[name] = ((out[i]?.results ?? []) as Record<string, unknown>[]).map(normalizeRow);
91
+ });
92
+ return { format: "a2a-exposed-export", version: 1, exportedAt: now.toISOString(), tables };
93
+ }
94
+
95
+ /** DELETE then INSERT for every allowlisted table. Safe to run on a database that already has the schema. */
96
+ export function toSql(file: ExportFile): string {
97
+ if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
98
+ if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
99
+ const lines = [
100
+ `-- a2a-exposed-export ${file.version}`,
101
+ `-- exported_at ${file.exportedAt}`,
102
+ ];
103
+ for (const name of EXPORT_TABLES) {
104
+ lines.push(`DELETE FROM ${quoteIdent(name)};`);
105
+ for (const row of file.tables[name] ?? []) {
106
+ const cols = Object.keys(row);
107
+ if (!cols.length) continue;
108
+ lines.push(`INSERT INTO ${quoteIdent(name)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map((c) => sqlLit(row[c])).join(", ")});`);
109
+ }
110
+ }
111
+ return lines.join("\n") + "\n";
112
+ }
113
+
114
+ export interface BookmarkStorage {
115
+ getBookmarkForTime?(timeMs: number): Promise<string>;
116
+ onNextSessionRestoreBookmark?(bookmark: string): Promise<unknown> | unknown;
117
+ }
118
+
119
+ /** Admin RPC helper. The storage runtime's method is called only when it exists. */
120
+ export async function bookmarkForTime(storage: BookmarkStorage, timeMs: number): Promise<{ bookmark: string }> {
121
+ if (typeof timeMs !== "number" || !Number.isFinite(timeMs)) throw new ExportError("bookmarkForTime: timeMs must be a finite number");
122
+ if (typeof storage.getBookmarkForTime !== "function") throw new ExportError("bookmarkForTime: point-in-time restore is not available on this storage");
123
+ const bookmark = await storage.getBookmarkForTime(timeMs);
124
+ if (typeof bookmark !== "string" || !bookmark) throw new ExportError("bookmarkForTime: storage returned no bookmark");
125
+ return { bookmark };
126
+ }
127
+
128
+ /**
129
+ * How long the RPC result is given to leave before `ctx.abort()`.
130
+ * The reset waits out this turn and a busy caller: aborting sooner drops the result.
131
+ */
132
+ export const RESTORE_RESTART_DELAY_MS = 1000;
133
+
134
+ /**
135
+ * Schedule `bookmark` for the next session and return `{ ok: true }`. Does not reset the object: `ctx.abort()` in
136
+ * this turn would drop the RPC. The Durable Object calls `restartAfterResult` after this returns. A storage error
137
+ * is thrown and the current database keeps running.
138
+ */
139
+ export async function restoreBookmark(storage: BookmarkStorage, bookmark: string): Promise<{ ok: true; undo?: string }> {
140
+ if (typeof bookmark !== "string" || bookmark.length < 1 || bookmark.length > 4096) throw new ExportError("restoreBookmark: bookmark is invalid");
141
+ if (typeof storage.onNextSessionRestoreBookmark !== "function") throw new ExportError("restoreBookmark: point-in-time restore is not available on this storage");
142
+ let raw: unknown;
143
+ try {
144
+ raw = await storage.onNextSessionRestoreBookmark(bookmark);
145
+ } catch (e) {
146
+ if (e instanceof ExportError) throw e;
147
+ throw new ExportError(`restoreBookmark: ${e instanceof Error ? e.message : String(e)}`);
148
+ }
149
+ const undo = typeof raw === "string" && raw.length > 0 ? raw : undefined;
150
+ return undo ? { ok: true, undo } : { ok: true };
151
+ }
152
+
153
+ /** Queue `restart` so the caller can return first. The timer is longer than this turn, so the RPC result is delivered. */
154
+ export function restartAfterResult(waitUntil: (p: Promise<unknown>) => void, restart: () => void, wait: (ms: number) => Promise<void>): void {
155
+ waitUntil(wait(RESTORE_RESTART_DELAY_MS).then(() => { restart(); }));
156
+ }