@substrat-run/control-plane-api 0.49.0 → 0.50.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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The scope-backup write seam (#493) — where a reap's recoverable copy goes.
3
+ *
4
+ * A reap frees a scope's Durable Object storage because Cloudflare never garbage-
5
+ * collects a DO, and it is irreversible. Before #493 the operator had to REMEMBER to
6
+ * take a copy first, from a different surface; this seam is what makes "a reap always
7
+ * leaves a recoverable copy" a property of the route instead of a habit.
8
+ *
9
+ * Why a dump and not a snapshot fork. The obvious cheap move — `orchestratedSnapshot`
10
+ * before the reap — does not survive the case the flow exists for. A fork is provisioned
11
+ * INSIDE the vertical's own deployment and activated, so (a) its bytes live in the very
12
+ * deployment a retirement is about to delete, and (b) it counts as a live scope in
13
+ * `countScopesForVertical`, which means it re-blocks the `deleteVertical` the reap was
14
+ * clearing. A dump leaves the deployment entirely, and `POST …/restore` already loads one
15
+ * back, so export→store→restore is the round trip that actually closes.
16
+ *
17
+ * Provider-neutral by the same posture as `ObservabilityReader` and `DeployVerticalFn`:
18
+ * the contract lives here, an implementation lives in its own module (`r2-backups.ts` for
19
+ * Cloudflare R2), and the host injects one. A backup is addressed by (tenant, scope,
20
+ * capturedAt) rather than by a store-shaped key, so the key scheme stays the store's
21
+ * private business and no caller can build a path into someone else's tenant.
22
+ *
23
+ * Fidelity is deliberately FULL, never masked (#493). A masked dump has its PII redacted,
24
+ * so restoring one produces a structurally-valid but factually wrong scope — a backup that
25
+ * cannot restore is a false promise, and the promise is the whole point. This is not the
26
+ * §6 governed pull: the bytes go platform→platform and are never handed to a caller, which
27
+ * is why the export route's masking default does not apply here.
28
+ */
29
+ import type { DirectoryBackup, DirectoryDump, ScopeBackup, ScopeDump } from '@substrat-run/contracts';
30
+ export type { ScopeBackup, DirectoryBackup };
31
+ export interface ScopeBackupStore {
32
+ /**
33
+ * Store one full-fidelity dump and return its metadata. Must be durable before it
34
+ * returns: the reap's whole guarantee is that this resolved before any byte was wiped.
35
+ */
36
+ put(input: {
37
+ vertical: string | null;
38
+ dump: ScopeDump;
39
+ }): Promise<ScopeBackup>;
40
+ /** Every backup held for one scope, newest first. */
41
+ list(input: {
42
+ tenantId: string;
43
+ scopeId: string;
44
+ }): Promise<ScopeBackup[]>;
45
+ /** One backup's dump, by its capture time. Null when no such copy is held. */
46
+ get(input: {
47
+ tenantId: string;
48
+ scopeId: string;
49
+ capturedAt: string;
50
+ }): Promise<ScopeDump | null>;
51
+ }
52
+ /**
53
+ * The DIRECTORY-backup store (#40) — the platform's own copy, not a tenant's.
54
+ *
55
+ * A sibling seam rather than a widened `ScopeBackupStore`, because the two are the same
56
+ * mechanism serving different jobs and the difference is worth keeping in the types.
57
+ * A scope backup is taken at a MOMENT — before a reap, before a migration — and is
58
+ * addressed by the scope it came from. A directory backup is taken on a SCHEDULE, has
59
+ * no tenant to be addressed by, and is pruned to a retention window. Only this one
60
+ * needs `delete`, and only this one is ever written by a sweep with no operator behind
61
+ * it.
62
+ *
63
+ * What it defends against is also different, and the distinction is the whole design
64
+ * (#40's own analysis). Per-scope point-in-time recovery already covers corruption
65
+ * inside the account — ~30 days, continuous, and strictly better than a daily copy, so
66
+ * scheduled per-scope backups are deliberately NOT built. The directory is the case PITR
67
+ * cannot answer: it is one Durable Object, and a bug that deletes it outright leaves
68
+ * nothing to rewind. Copies live outside the DO for that reason.
69
+ *
70
+ * Honest scoping: with the store bound in the platform's OWN account (the shape shipped
71
+ * today), this survives losing the directory, not losing the account. The seam is
72
+ * provider-neutral so an off-account target is a drop-in — see control-plane.md §4.9.
73
+ */
74
+ export interface DirectoryBackupStore {
75
+ /**
76
+ * Store one full-fidelity directory dump and return its metadata. Durable before it
77
+ * returns, like the scope store's `put`.
78
+ */
79
+ put(input: {
80
+ dump: DirectoryDump;
81
+ }): Promise<DirectoryBackup>;
82
+ /** Every directory copy held, newest first. */
83
+ list(): Promise<DirectoryBackup[]>;
84
+ /** One copy's dump, by its capture time. Null when no such copy is held. */
85
+ get(input: {
86
+ capturedAt: string;
87
+ }): Promise<DirectoryDump | null>;
88
+ /** Drop one copy — how the retention window is enforced. Missing is not an error. */
89
+ delete(input: {
90
+ capturedAt: string;
91
+ }): Promise<void>;
92
+ }
93
+ //# sourceMappingURL=backups.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backups.d.ts","sourceRoot":"","sources":["../src/backups.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAKtG,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,CAAC;AAE7C,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,GAAG,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,EAAE,SAAS,CAAA;KAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E,qDAAqD;IACrD,IAAI,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3E,8EAA8E;IAC9E,GAAG,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;CAClG;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,GAAG,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,aAAa,CAAA;KAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IAC9D,+CAA+C;IAC/C,IAAI,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IACnC,4EAA4E;IAC5E,GAAG,CAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAClE,qFAAqF;IACrF,MAAM,CAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtD"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The scope-backup write seam (#493) — where a reap's recoverable copy goes.
3
+ *
4
+ * A reap frees a scope's Durable Object storage because Cloudflare never garbage-
5
+ * collects a DO, and it is irreversible. Before #493 the operator had to REMEMBER to
6
+ * take a copy first, from a different surface; this seam is what makes "a reap always
7
+ * leaves a recoverable copy" a property of the route instead of a habit.
8
+ *
9
+ * Why a dump and not a snapshot fork. The obvious cheap move — `orchestratedSnapshot`
10
+ * before the reap — does not survive the case the flow exists for. A fork is provisioned
11
+ * INSIDE the vertical's own deployment and activated, so (a) its bytes live in the very
12
+ * deployment a retirement is about to delete, and (b) it counts as a live scope in
13
+ * `countScopesForVertical`, which means it re-blocks the `deleteVertical` the reap was
14
+ * clearing. A dump leaves the deployment entirely, and `POST …/restore` already loads one
15
+ * back, so export→store→restore is the round trip that actually closes.
16
+ *
17
+ * Provider-neutral by the same posture as `ObservabilityReader` and `DeployVerticalFn`:
18
+ * the contract lives here, an implementation lives in its own module (`r2-backups.ts` for
19
+ * Cloudflare R2), and the host injects one. A backup is addressed by (tenant, scope,
20
+ * capturedAt) rather than by a store-shaped key, so the key scheme stays the store's
21
+ * private business and no caller can build a path into someone else's tenant.
22
+ *
23
+ * Fidelity is deliberately FULL, never masked (#493). A masked dump has its PII redacted,
24
+ * so restoring one produces a structurally-valid but factually wrong scope — a backup that
25
+ * cannot restore is a false promise, and the promise is the whole point. This is not the
26
+ * §6 governed pull: the bytes go platform→platform and are never handed to a caller, which
27
+ * is why the export route's masking default does not apply here.
28
+ */
29
+ export {};
30
+ //# sourceMappingURL=backups.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backups.js","sourceRoot":"","sources":["../src/backups.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The scheduled directory backup (#40) — the platform's own disaster-recovery pass.
3
+ *
4
+ * `control-plane.md` names the stake without resolving it: *"The directory becomes a real
5
+ * database, with its own migrations and backup story… Losing it is losing the platform,
6
+ * not losing a cache."* Every OTHER database the platform holds is a scope, protected by
7
+ * ~30-day Durable Object point-in-time recovery — which is continuous, per-scope, and
8
+ * strictly better than any daily copy, so scheduled per-scope backups are deliberately not
9
+ * built here. The directory is the one database PITR cannot answer for: it is a single DO,
10
+ * and a control-plane bug that deletes it outright leaves nothing to rewind. So a copy
11
+ * lives OUTSIDE the DO, and this is what takes it.
12
+ *
13
+ * Driven by the same cron that drives the platform sweep, and the cadence is enforced HERE
14
+ * rather than by a second trigger. A quarter-hourly sweep asking "is a copy due?" and answering
15
+ * from the store is idempotent, survives a missed tick (the next pass takes it, late
16
+ * rather than never), and needs no durable "last run" state of its own — the newest stored
17
+ * copy IS that state. A dedicated daily trigger would have to be right about time zones,
18
+ * deploys and restarts to do the same job worse.
19
+ */
20
+ import type { DirectoryBackup, PlatformActorId } from '@substrat-run/contracts';
21
+ import type { HostAdmin } from '@substrat-run/kernel';
22
+ import type { DirectoryBackupStore } from './backups.js';
23
+ export interface DirectoryBackupOptions {
24
+ admin: HostAdmin;
25
+ store: DirectoryBackupStore;
26
+ /** The platform actor the export is audited under. */
27
+ actor: PlatformActorId;
28
+ /** Hours between copies (default 24). */
29
+ intervalHours?: number;
30
+ /** How many copies to keep (default 30). Older ones are deleted after a successful put. */
31
+ retain?: number;
32
+ /** Take a copy regardless of cadence — what the manual "back up now" route passes. */
33
+ force?: boolean;
34
+ /** Injected clock, for tests. */
35
+ now?: () => Date;
36
+ }
37
+ export interface DirectoryBackupResult {
38
+ /** The copy taken this pass, or null when the cadence guard skipped it. */
39
+ taken: DirectoryBackup | null;
40
+ /** When nothing was taken: the copy that was still fresh enough to skip for. */
41
+ skippedFor: string | null;
42
+ /** Copies deleted by the retention window this pass. */
43
+ pruned: number;
44
+ }
45
+ /**
46
+ * Take a directory backup if one is due, then prune to the retention window.
47
+ *
48
+ * Order matters and is the inverse of the reap's (#493). A reap stores BEFORE it wipes,
49
+ * because the wipe is what the copy protects against. Here the prune runs AFTER a
50
+ * successful put, so a failed capture can never be the thing that deletes the last good
51
+ * copy — the worst case is an extra copy kept, never a missing one.
52
+ *
53
+ * Throws whatever the export or the store threw. The caller (a cron pass) decides what a
54
+ * failure means; it must not be silent, which is why nothing is swallowed here.
55
+ */
56
+ export declare function backupDirectoryIfDue(options: DirectoryBackupOptions): Promise<DirectoryBackupResult>;
57
+ //# sourceMappingURL=directory-backup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory-backup.d.ts","sourceRoot":"","sources":["../src/directory-backup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAUzD,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,SAAS,CAAC;IACjB,KAAK,EAAE,oBAAoB,CAAC;IAC5B,sDAAsD;IACtD,KAAK,EAAE,eAAe,CAAC;IACvB,yCAAyC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,2EAA2E;IAC3E,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IAC9B,gFAAgF;IAChF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CA+BhC"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The scheduled directory backup (#40) — the platform's own disaster-recovery pass.
3
+ *
4
+ * `control-plane.md` names the stake without resolving it: *"The directory becomes a real
5
+ * database, with its own migrations and backup story… Losing it is losing the platform,
6
+ * not losing a cache."* Every OTHER database the platform holds is a scope, protected by
7
+ * ~30-day Durable Object point-in-time recovery — which is continuous, per-scope, and
8
+ * strictly better than any daily copy, so scheduled per-scope backups are deliberately not
9
+ * built here. The directory is the one database PITR cannot answer for: it is a single DO,
10
+ * and a control-plane bug that deletes it outright leaves nothing to rewind. So a copy
11
+ * lives OUTSIDE the DO, and this is what takes it.
12
+ *
13
+ * Driven by the same cron that drives the platform sweep, and the cadence is enforced HERE
14
+ * rather than by a second trigger. A quarter-hourly sweep asking "is a copy due?" and answering
15
+ * from the store is idempotent, survives a missed tick (the next pass takes it, late
16
+ * rather than never), and needs no durable "last run" state of its own — the newest stored
17
+ * copy IS that state. A dedicated daily trigger would have to be right about time zones,
18
+ * deploys and restarts to do the same job worse.
19
+ */
20
+ /** One copy a day; the sweep runs far more often and skips in between. */
21
+ const DEFAULT_INTERVAL_HOURS = 24;
22
+ /**
23
+ * Thirty copies — a month of daily history, matching the DO PITR horizon so the two
24
+ * defences expire together rather than leaving a window covered by neither.
25
+ */
26
+ const DEFAULT_RETAIN = 30;
27
+ /**
28
+ * Take a directory backup if one is due, then prune to the retention window.
29
+ *
30
+ * Order matters and is the inverse of the reap's (#493). A reap stores BEFORE it wipes,
31
+ * because the wipe is what the copy protects against. Here the prune runs AFTER a
32
+ * successful put, so a failed capture can never be the thing that deletes the last good
33
+ * copy — the worst case is an extra copy kept, never a missing one.
34
+ *
35
+ * Throws whatever the export or the store threw. The caller (a cron pass) decides what a
36
+ * failure means; it must not be silent, which is why nothing is swallowed here.
37
+ */
38
+ export async function backupDirectoryIfDue(options) {
39
+ const now = options.now?.() ?? new Date();
40
+ const retain = options.retain ?? DEFAULT_RETAIN;
41
+ const intervalMs = (options.intervalHours ?? DEFAULT_INTERVAL_HOURS) * 3_600_000;
42
+ // The store IS the schedule's memory: newest copy, newest first (the store sorts).
43
+ const existing = await options.store.list();
44
+ const newest = existing[0];
45
+ if (!options.force && newest) {
46
+ const age = now.getTime() - new Date(newest.capturedAt).getTime();
47
+ // `age < 0` — a copy stamped in the future, from a clock skew or a hand-uploaded
48
+ // object — counts as fresh. Skipping is the conservative read: taking a copy is
49
+ // cheap, but treating a future timestamp as "infinitely overdue" would take one on
50
+ // every single pass until real time caught up.
51
+ if (age < intervalMs)
52
+ return { taken: null, skippedFor: newest.capturedAt, pruned: 0 };
53
+ }
54
+ const dump = await options.admin.exportDirectory(options.actor);
55
+ const taken = await options.store.put({ dump });
56
+ // Prune from a re-read list, not from `existing`: the copy just written has to be in
57
+ // the reckoning, or a retention window of N would keep N+1 forever.
58
+ let pruned = 0;
59
+ if (retain > 0) {
60
+ const all = await options.store.list();
61
+ for (const stale of all.slice(retain)) {
62
+ await options.store.delete({ capturedAt: stale.capturedAt });
63
+ pruned += 1;
64
+ }
65
+ }
66
+ return { taken, skippedFor: null, pruned };
67
+ }
68
+ //# sourceMappingURL=directory-backup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory-backup.js","sourceRoot":"","sources":["../src/directory-backup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAMH,0EAA0E;AAC1E,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAClC;;;GAGG;AACH,MAAM,cAAc,GAAG,EAAE,CAAC;AA0B1B;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,OAA+B;IAE/B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC;IAC1C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,cAAc,CAAC;IAChD,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,aAAa,IAAI,sBAAsB,CAAC,GAAG,SAAS,CAAC;IAEjF,mFAAmF;IACnF,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IAC5C,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC3B,IAAI,CAAC,OAAO,CAAC,KAAK,IAAI,MAAM,EAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO,EAAE,GAAG,IAAI,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,OAAO,EAAE,CAAC;QAClE,iFAAiF;QACjF,gFAAgF;QAChF,mFAAmF;QACnF,+CAA+C;QAC/C,IAAI,GAAG,GAAG,UAAU;YAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACzF,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAChE,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;IAEhD,qFAAqF;IACrF,oEAAoE;IACpE,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;QACf,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACvC,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;YACtC,MAAM,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC;YAC7D,MAAM,IAAI,CAAC,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;AAC7C,CAAC"}
package/dist/index.d.ts CHANGED
@@ -18,4 +18,8 @@ export type { CustomHostnameProvisioner, CustomHostnameProvisionerOptions, Custo
18
18
  export type { ObservabilityReader, ServiceMetricsRow, RecentLogEvent } from './observability.js';
19
19
  export { createCfObservabilityReader } from './cf-observability.js';
20
20
  export type { CfObservabilityOptions } from './cf-observability.js';
21
+ export type { ScopeBackup, ScopeBackupStore, DirectoryBackup, DirectoryBackupStore, } from './backups.js';
22
+ export { createR2BackupStore, createR2DirectoryBackupStore } from './r2-backups.js';
23
+ export { backupDirectoryIfDue } from './directory-backup.js';
24
+ export type { DirectoryBackupOptions, DirectoryBackupResult } from './directory-backup.js';
21
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACjD,YAAY,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,EAC3B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACrG,YAAY,EACV,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,EACf,WAAW,EACX,SAAS,GACV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,yBAAyB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EACtB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACrG,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAChG,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,iBAAiB,EACjB,aAAa,EACb,aAAa,GACd,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,+BAA+B,EAC/B,WAAW,EACX,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,yBAAyB,EACzB,gCAAgC,EAChC,sBAAsB,EACtB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACjG,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC;AACpE,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACjD,YAAY,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,EAC3B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACrG,YAAY,EACV,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,EACf,WAAW,EACX,SAAS,GACV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,yBAAyB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EACtB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACrG,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAChG,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,iBAAiB,EACjB,aAAa,EACb,aAAa,GACd,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,+BAA+B,EAC/B,WAAW,EACX,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,yBAAyB,EACzB,gCAAgC,EAChC,sBAAsB,EACtB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACjG,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC;AACpE,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,YAAY,EACV,WAAW,EACX,gBAAgB,EAChB,eAAe,EACf,oBAAoB,GACrB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AAIpF,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -9,4 +9,9 @@ export { createWfpUploader, createWfpModulesFetcher, createWfpBindingsPatcher }
9
9
  export { blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, tenantStoreBindings, } from './tenant-stores.js';
10
10
  export { createCustomHostnameProvisioner, mapCfStatus, extractRecords, reconcilePendingHostnames, isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
11
11
  export { createCfObservabilityReader } from './cf-observability.js';
12
+ export { createR2BackupStore, createR2DirectoryBackupStore } from './r2-backups.js';
13
+ // #40 — the scheduled directory copy. Exported as a function the CP worker's cron calls,
14
+ // not as a route with a timer behind it: this package stays library-only, exactly as the
15
+ // platform-request drain does (the recurrence has to come from a deployment, #444).
16
+ export { backupDirectoryIfDue } from './directory-backup.js';
12
17
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAEjD,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,EAC3B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAUrG,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEpE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,GAUvB,MAAM,qBAAqB,CAAC;AAO7B,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAQhG,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,+BAA+B,EAC/B,WAAW,EACX,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAQ/B,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAEjD,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,EAC3B,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAUrG,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEpE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,GAUvB,MAAM,qBAAqB,CAAC;AAO7B,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAQhG,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,+BAA+B,EAC/B,WAAW,EACX,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAQ/B,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC;AAQpE,OAAO,EAAE,mBAAmB,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpF,yFAAyF;AACzF,yFAAyF;AACzF,oFAAoF;AACpF,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/mask.d.ts CHANGED
@@ -1,4 +1,19 @@
1
1
  import type { ScopeDumpTable } from '@substrat-run/contracts';
2
2
  /** Mask one dump in place-shape (returns new arrays; never mutates the input). */
3
3
  export declare function maskDump(tables: ScopeDumpTable[]): ScopeDumpTable[];
4
+ /**
5
+ * The same heuristic applied to plain JSON records — the directory half of a tenant
6
+ * export (#36).
7
+ *
8
+ * A tenant export carries two kinds of thing: scope databases (table-shaped, masked by
9
+ * `maskDump` above) and directory records (object-shaped — a tenant's display name, an
10
+ * org's name, an identity link's external id, which is usually an email). Both halves
11
+ * must be masked by the SAME rule, or the default-masked promise is only half true and
12
+ * the leak is in the half nobody looked at.
13
+ *
14
+ * So this reuses `maskJsonValue` rather than growing a second heuristic: one PII column
15
+ * list, one recursive sweep, two entry points. Ids and timestamps pass through — the
16
+ * sweep targets free text, and ids are what keep an export intelligible.
17
+ */
18
+ export declare function maskRecords<T>(records: readonly T[]): T[];
4
19
  //# sourceMappingURL=mask.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mask.d.ts","sourceRoot":"","sources":["../src/mask.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAoD9D,kFAAkF;AAClF,wBAAgB,QAAQ,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,cAAc,EAAE,CAqBnE"}
1
+ {"version":3,"file":"mask.d.ts","sourceRoot":"","sources":["../src/mask.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AA2D9D,kFAAkF;AAClF,wBAAgB,QAAQ,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,cAAc,EAAE,CAqBnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,GAAG,CAAC,EAAE,CAEzD"}
package/dist/mask.js CHANGED
@@ -22,7 +22,14 @@
22
22
  */
23
23
  // Free-text/PII column names. `name` is included on purpose: entity names
24
24
  // (customers, properties, contacts) are customer data even when they look benign.
25
- const PII_COLUMN = /(^|_)(email|e?mail_address|phone|mobile|tel|address|street|city|postal|zip|ssn|personnummer|name|first_name|last_name|full_name|contact|note|notes|comment|comments|message|subject|body|description)($|_)/i;
25
+ //
26
+ // `external_id` earns its place from the platform's OWN schema (#36): an identity link's
27
+ // `externalId` is the provider's subject, which in practice is very often the person's
28
+ // email address — so a tenant export that swept only the obvious columns would hand out
29
+ // the one field most likely to name a human. It is the deliberately lossy direction of
30
+ // the trade: an opaque third-party id (a document ref, an upstream order number) gets
31
+ // masked too, which costs a masked pull some fidelity and costs a leak nothing.
32
+ const PII_COLUMN = /(^|_)(email|e?mail_address|phone|mobile|tel|address|street|city|postal|zip|ssn|personnummer|name|first_name|last_name|full_name|contact|external_id|note|notes|comment|comments|message|subject|body|description)($|_)/i;
26
33
  // Columns that carry JSON documents worth sweeping by key rather than blanking.
27
34
  const JSON_COLUMN = /(^|_)(payload|detail|details|data|before|after)($|_)/i;
28
35
  const MASKED = '[masked]';
@@ -68,4 +75,21 @@ export function maskDump(tables) {
68
75
  return { ...t, rows };
69
76
  });
70
77
  }
78
+ /**
79
+ * The same heuristic applied to plain JSON records — the directory half of a tenant
80
+ * export (#36).
81
+ *
82
+ * A tenant export carries two kinds of thing: scope databases (table-shaped, masked by
83
+ * `maskDump` above) and directory records (object-shaped — a tenant's display name, an
84
+ * org's name, an identity link's external id, which is usually an email). Both halves
85
+ * must be masked by the SAME rule, or the default-masked promise is only half true and
86
+ * the leak is in the half nobody looked at.
87
+ *
88
+ * So this reuses `maskJsonValue` rather than growing a second heuristic: one PII column
89
+ * list, one recursive sweep, two entry points. Ids and timestamps pass through — the
90
+ * sweep targets free text, and ids are what keep an export intelligible.
91
+ */
92
+ export function maskRecords(records) {
93
+ return records.map((r) => maskJsonValue(r, false));
94
+ }
71
95
  //# sourceMappingURL=mask.js.map
package/dist/mask.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"mask.js","sourceRoot":"","sources":["../src/mask.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,0EAA0E;AAC1E,kFAAkF;AAClF,MAAM,UAAU,GAAG,6MAA6M,CAAC;AAEjO,gFAAgF;AAChF,MAAM,WAAW,GAAG,uDAAuD,CAAC;AAE5E,MAAM,MAAM,GAAG,UAAU,CAAC;AAE1B,iFAAiF;AACjF,iFAAiF;AACjF,MAAM,UAAU,GAAG,CAAC,IAAY,EAAW,EAAE,CAC3C,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,oBAAoB,EAAE,OAAO,CAAC,CAAC,CAAC;AAE/D,SAAS,aAAa,CAAC,KAAc,EAAE,UAAmB;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;IAClE,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC;IAChF,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,MAAM,GAAG,GAA4B,EAAE,CAAC;QACxC,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;YACtE,GAAG,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,EAAE,UAAU,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;QACzD,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,QAAQ,CAAC,MAAwB;IAC/C,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACtB,MAAM,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;QACpD,MAAM,QAAQ,GAAG,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3D,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,CAAC,CAAC;QAChE,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAC9B,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;YAClB,IAAI,OAAO,IAAI,KAAK,QAAQ;gBAAE,OAAO,IAAI,CAAC;YAC1C,IAAI,OAAO,CAAC,CAAC,CAAC;gBAAE,OAAO,MAAM,CAAC;YAC9B,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;gBAChB,IAAI,CAAC;oBACH,OAAO,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;gBAChE,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO,IAAI,CAAC,CAAC,6DAA6D;gBAC5E,CAAC;YACH,CAAC;YACD,OAAO,IAAI,CAAC;QACd,CAAC,CAAC,CACH,CAAC;QACF,OAAO,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC;IACxB,CAAC,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"mask.js","sourceRoot":"","sources":["../src/mask.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The Cloudflare R2 implementation of the `ScopeBackupStore` seam (#493) — the platform's
3
+ * own backup bucket, bound on the control-plane worker.
4
+ *
5
+ * Structurally typed against the binding (`R2BucketLike`) rather than importing a
6
+ * Cloudflare SDK, the same way `adapter-cloudflare/src/r2.ts` wraps a tenant blob store:
7
+ * this package stays SDK-free and the fake in tests is an object literal.
8
+ *
9
+ * One bucket, held by the PLATFORM, not by the tenant. A tenant-held bucket would be
10
+ * deleted by the very teardown these copies exist to survive.
11
+ */
12
+ import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js';
13
+ export declare function createR2BackupStore(bucket: unknown): ScopeBackupStore;
14
+ /**
15
+ * The Cloudflare R2 implementation of `DirectoryBackupStore` (#40).
16
+ *
17
+ * Deliberately a separate factory over the SAME binding shape rather than a mode flag on
18
+ * `createR2BackupStore`: the two stores answer different questions and only this one can
19
+ * delete. A deployment may point them at one bucket or two — the prefixes keep them apart
20
+ * either way, and pointing THIS one at a bucket in another account is the upgrade path
21
+ * from "survives losing the directory" to "survives losing the account".
22
+ */
23
+ export declare function createR2DirectoryBackupStore(bucket: unknown): DirectoryBackupStore;
24
+ //# sourceMappingURL=r2-backups.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"r2-backups.d.ts","sourceRoot":"","sources":["../src/r2-backups.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH,OAAO,KAAK,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAsC3E,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,CAsErE;AAcD;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,OAAO,GAAG,oBAAoB,CAgElF"}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The Cloudflare R2 implementation of the `ScopeBackupStore` seam (#493) — the platform's
3
+ * own backup bucket, bound on the control-plane worker.
4
+ *
5
+ * Structurally typed against the binding (`R2BucketLike`) rather than importing a
6
+ * Cloudflare SDK, the same way `adapter-cloudflare/src/r2.ts` wraps a tenant blob store:
7
+ * this package stays SDK-free and the fake in tests is an object literal.
8
+ *
9
+ * One bucket, held by the PLATFORM, not by the tenant. A tenant-held bucket would be
10
+ * deleted by the very teardown these copies exist to survive.
11
+ */
12
+ import { directoryDump, scopeDump, } from '@substrat-run/contracts';
13
+ /**
14
+ * Keys are `scopes/<tenantId>/<scopeId>/<capturedAt>.json`, so a scope's copies are one
15
+ * prefix list and a tenant's are one level up. The scheme is private to this module —
16
+ * callers address a backup by (tenant, scope, capturedAt) and never build a path.
17
+ */
18
+ function backupKey(tenantId, scopeId, capturedAt) {
19
+ return `scopes/${tenantId}/${scopeId}/${capturedAt}.json`;
20
+ }
21
+ function scopePrefix(tenantId, scopeId) {
22
+ return `scopes/${tenantId}/${scopeId}/`;
23
+ }
24
+ export function createR2BackupStore(bucket) {
25
+ const r2 = bucket;
26
+ return {
27
+ async put({ vertical, dump }) {
28
+ const body = JSON.stringify(dump);
29
+ // Byte length, not string length: the size is meant to be the stored object's,
30
+ // and a dump full of non-ASCII would otherwise under-report.
31
+ const size = new TextEncoder().encode(body).length;
32
+ const backup = {
33
+ tenantId: dump.tenantId,
34
+ scopeId: dump.scopeId,
35
+ vertical,
36
+ capturedAt: dump.capturedAt,
37
+ size,
38
+ tables: dump.tables.length,
39
+ };
40
+ await r2.put(backupKey(dump.tenantId, dump.scopeId, dump.capturedAt), body, {
41
+ httpMetadata: { contentType: 'application/json' },
42
+ // Mirrored into custom metadata so `list` can answer without fetching every
43
+ // dump — a scope's backups are megabytes, its listing is a table row.
44
+ customMetadata: {
45
+ tenantId: backup.tenantId,
46
+ scopeId: backup.scopeId,
47
+ capturedAt: backup.capturedAt,
48
+ tables: String(backup.tables),
49
+ ...(vertical ? { vertical } : {}),
50
+ },
51
+ });
52
+ return backup;
53
+ },
54
+ async list({ tenantId, scopeId }) {
55
+ const prefix = scopePrefix(tenantId, scopeId);
56
+ const out = [];
57
+ let cursor;
58
+ do {
59
+ const page = await r2.list({
60
+ prefix,
61
+ include: ['customMetadata'],
62
+ ...(cursor ? { cursor } : {}),
63
+ });
64
+ for (const o of page.objects) {
65
+ const meta = o.customMetadata ?? {};
66
+ // The key is the fallback address: an object written before the metadata
67
+ // existed (or by a manual upload) still lists rather than vanishing.
68
+ const capturedAt = meta.capturedAt ?? o.key.slice(prefix.length).replace(/\.json$/, '');
69
+ out.push({
70
+ tenantId,
71
+ scopeId,
72
+ vertical: meta.vertical ?? null,
73
+ capturedAt,
74
+ size: o.size,
75
+ tables: meta.tables ? Number(meta.tables) : 0,
76
+ });
77
+ }
78
+ cursor = page.truncated ? page.cursor : undefined;
79
+ } while (cursor);
80
+ // Newest first. ISO 8601 sorts lexicographically, which is why `capturedAt` is
81
+ // the key's last segment.
82
+ return out.sort((a, b) => (a.capturedAt < b.capturedAt ? 1 : -1));
83
+ },
84
+ async get({ tenantId, scopeId, capturedAt }) {
85
+ const obj = await r2.get(backupKey(tenantId, scopeId, capturedAt));
86
+ if (!obj)
87
+ return null;
88
+ // Parsed through the contract on the way out: a corrupted or hand-edited object
89
+ // fails HERE, loudly, rather than at the restore that trusted it.
90
+ return scopeDump.parse(JSON.parse(await obj.text()));
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * Keys are `directory/<capturedAt>.json` — one flat prefix, because there is exactly one
96
+ * directory per deployment and `capturedAt` is a copy's whole address. Sharing a bucket
97
+ * with the scope backups above is safe by construction: `scopes/` and `directory/` cannot
98
+ * collide, and neither prefix is ever built by a caller.
99
+ */
100
+ function directoryKey(capturedAt) {
101
+ return `directory/${capturedAt}.json`;
102
+ }
103
+ const DIRECTORY_PREFIX = 'directory/';
104
+ /**
105
+ * The Cloudflare R2 implementation of `DirectoryBackupStore` (#40).
106
+ *
107
+ * Deliberately a separate factory over the SAME binding shape rather than a mode flag on
108
+ * `createR2BackupStore`: the two stores answer different questions and only this one can
109
+ * delete. A deployment may point them at one bucket or two — the prefixes keep them apart
110
+ * either way, and pointing THIS one at a bucket in another account is the upgrade path
111
+ * from "survives losing the directory" to "survives losing the account".
112
+ */
113
+ export function createR2DirectoryBackupStore(bucket) {
114
+ const r2 = bucket;
115
+ return {
116
+ async put({ dump }) {
117
+ const body = JSON.stringify(dump);
118
+ // Byte length, not string length — the size is the stored object's.
119
+ const size = new TextEncoder().encode(body).length;
120
+ const backup = {
121
+ capturedAt: dump.capturedAt,
122
+ size,
123
+ tables: dump.tables.length,
124
+ };
125
+ await r2.put(directoryKey(dump.capturedAt), body, {
126
+ httpMetadata: { contentType: 'application/json' },
127
+ // Mirrored into custom metadata so `list` answers without fetching every dump —
128
+ // a directory copy is megabytes, its listing is a table row.
129
+ customMetadata: {
130
+ capturedAt: backup.capturedAt,
131
+ tables: String(backup.tables),
132
+ },
133
+ });
134
+ return backup;
135
+ },
136
+ async list() {
137
+ const out = [];
138
+ let cursor;
139
+ do {
140
+ const page = await r2.list({
141
+ prefix: DIRECTORY_PREFIX,
142
+ include: ['customMetadata'],
143
+ ...(cursor ? { cursor } : {}),
144
+ });
145
+ for (const o of page.objects) {
146
+ const meta = o.customMetadata ?? {};
147
+ // The key is the fallback address, as in the scope store: an object written
148
+ // before the metadata existed still lists rather than vanishing.
149
+ const capturedAt = meta.capturedAt ?? o.key.slice(DIRECTORY_PREFIX.length).replace(/\.json$/, '');
150
+ out.push({
151
+ capturedAt,
152
+ size: o.size,
153
+ tables: meta.tables ? Number(meta.tables) : 0,
154
+ });
155
+ }
156
+ cursor = page.truncated ? page.cursor : undefined;
157
+ } while (cursor);
158
+ // Newest first — ISO 8601 sorts lexicographically, which is why `capturedAt` is
159
+ // the key's last segment. The daily-cadence guard reads element 0.
160
+ return out.sort((a, b) => (a.capturedAt < b.capturedAt ? 1 : -1));
161
+ },
162
+ async get({ capturedAt }) {
163
+ const obj = await r2.get(directoryKey(capturedAt));
164
+ if (!obj)
165
+ return null;
166
+ // Parsed through the contract on the way out, so a corrupted object fails HERE
167
+ // rather than half-way through the restore that trusted it.
168
+ return directoryDump.parse(JSON.parse(await obj.text()));
169
+ },
170
+ async delete({ capturedAt }) {
171
+ await r2.delete(directoryKey(capturedAt));
172
+ },
173
+ };
174
+ }
175
+ //# sourceMappingURL=r2-backups.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"r2-backups.js","sourceRoot":"","sources":["../src/r2-backups.ts"],"names":[],"mappings":"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"}