@substrat-run/control-plane-api 0.176.0 → 0.178.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.
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +612 -210
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +80 -16
- package/dist/backups.d.ts.map +1 -1
- package/dist/backups.js +3 -0
- package/dist/backups.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/builder-turns.d.ts +16 -0
- package/dist/builder-turns.d.ts.map +1 -0
- package/dist/builder-turns.js +43 -0
- package/dist/builder-turns.js.map +1 -0
- package/dist/connection-binding-relay.d.ts +28 -1
- package/dist/connection-binding-relay.d.ts.map +1 -1
- package/dist/connection-binding-relay.js +87 -2
- package/dist/connection-binding-relay.js.map +1 -1
- package/dist/connection-relay.d.ts +2 -2
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.js.map +1 -1
- package/dist/directory-backup.d.ts +4 -0
- package/dist/directory-backup.d.ts.map +1 -1
- package/dist/directory-backup.js +60 -7
- package/dist/directory-backup.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lake-gateway.d.ts +9 -0
- package/dist/lake-gateway.d.ts.map +1 -1
- package/dist/lake-gateway.js +18 -1
- package/dist/lake-gateway.js.map +1 -1
- package/dist/migration-extract.d.ts +92 -0
- package/dist/migration-extract.d.ts.map +1 -0
- package/dist/migration-extract.js +217 -0
- package/dist/migration-extract.js.map +1 -0
- package/dist/platform-entry.d.ts +2 -0
- package/dist/platform-entry.d.ts.map +1 -1
- package/dist/platform-entry.generated.d.ts +1 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +2 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-entry.js +2 -1
- package/dist/platform-entry.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +10 -12
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +47 -24
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/r2-backups.d.ts +8 -5
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +232 -56
- package/dist/r2-backups.js.map +1 -1
- package/dist/seal.d.ts +5 -2
- package/dist/seal.d.ts.map +1 -1
- package/dist/seal.js +3 -0
- package/dist/seal.js.map +1 -1
- package/dist/staff-client.d.ts +10 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +4 -2
- package/dist/staff-client.js.map +1 -1
- package/dist/store-entry.d.ts.map +1 -1
- package/dist/store-entry.generated.d.ts +2 -7
- package/dist/store-entry.generated.d.ts.map +1 -1
- package/dist/store-entry.generated.js +5 -10
- package/dist/store-entry.generated.js.map +1 -1
- package/dist/store-entry.js +3 -2
- package/dist/store-entry.js.map +1 -1
- package/dist/store-releases.d.ts +18 -4
- package/dist/store-releases.d.ts.map +1 -1
- package/dist/store-releases.js +74 -11
- package/dist/store-releases.js.map +1 -1
- package/dist/store-script.d.ts +12 -2
- package/dist/store-script.d.ts.map +1 -1
- package/dist/store-script.js +47 -9
- package/dist/store-script.js.map +1 -1
- package/dist/vertical-client.d.ts +6 -0
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +16 -0
- package/dist/vertical-client.js.map +1 -1
- package/package.json +9 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { LIST_PAGE_MAX, mayHoldScopeStores, scopeStoresDeclaredIn, STORE_DRIVERS, issueScopeStoreEnvelope, SCOPE_STORE_ENVELOPE_HEADER, SCOPE_STORE_SIGNATURE_HEADER, ScopeStoreKeyError, scopeStoreObjectName, scopeStoreScriptName, storeEraseResult,
|
|
2
|
-
import { callReservedStoreOperation } from '@substrat-run/kernel';
|
|
1
|
+
import { LIST_PAGE_MAX, mayHoldScopeStores, scopeStoresDeclaredIn, STORE_DRIVERS, issueScopeStoreEnvelope, SCOPE_STORE_ENVELOPE_HEADER, SCOPE_STORE_SIGNATURE_HEADER, ScopeStoreKeyError, STORE_CALL_MAX_BYTES, scopeStoreBodyBytes, scopeStoreObjectName, scopeStoreScriptName, storeEraseResult, storeDriverDumpPageBytes, storeSizeResult, StoreError, } from '@substrat-run/contracts';
|
|
2
|
+
import { callReservedStoreOperation, readStoreDump, writeStoreDump } from '@substrat-run/kernel';
|
|
3
3
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
4
4
|
import { storedDeployManifest } from './deploy.js';
|
|
5
5
|
/** Bindings a version manifest declares. Missing JSON is none. Unreadable JSON fails closed. */
|
|
@@ -65,34 +65,48 @@ export async function unrouteScope(admin, actor, scope) {
|
|
|
65
65
|
* ordinary install whose store the router serves. A pin that names another script than the
|
|
66
66
|
* pointer's is unknown, as #2242's erasure gate has it.
|
|
67
67
|
*
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* that record says the vertical has declared none, the answer is none; when it cannot be read, the
|
|
76
|
-
* refusal stands. A bind or promote that lands after the read is the erasure fence's to refuse,
|
|
77
|
-
* as it is for a version that declares none.
|
|
68
|
+
* The vertical's record of declared bindings is read first (`declaredRecordOf`): with no driver
|
|
69
|
+
* registered, or a record that says the vertical has declared none, the answer is none; a record
|
|
70
|
+
* that cannot be read refuses. Only then is the running version read, and a version that cannot be
|
|
71
|
+
* named, a manifest that cannot be read, or one the gate (`mayHoldScopeStores`) says may hold a
|
|
72
|
+
* store without naming it refuses (503), rather than let a backup, a wipe, a replay or an erasure
|
|
73
|
+
* skip a store that may hold data (#2394). A bind or promote that lands after the read is the
|
|
74
|
+
* erasure fence's to refuse, as it is for a version that declares none.
|
|
78
75
|
*
|
|
79
76
|
* `running` is a resolution the caller already holds (`runningVersionOf`), so a caller that fences
|
|
80
77
|
* on the version reads it once and judges the stores against the same read.
|
|
81
78
|
*/
|
|
82
79
|
export async function runningVersionStoresOf(admin, actor, scope, drivers = STORE_DRIVERS, running) {
|
|
80
|
+
const record = await declaredRecordOf(admin, actor, scope, drivers);
|
|
81
|
+
return record.length === 0 ? [] : runningStoresOfDeclaring(admin, actor, scope, drivers, running);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The vertical's record of every binding any version declared, read first and once: a vertical
|
|
85
|
+
* that never declared a store answers none with this one read, which is what the sweep pays for
|
|
86
|
+
* almost every scope it meters or reaps. Sound because a store exists only under a binding some
|
|
87
|
+
* version declared: the router reaches a store only through a version row's manifest
|
|
88
|
+
* (`READ_ROUTE_SQL`), every insert of a version row records its bindings in the same transaction,
|
|
89
|
+
* and the read itself first indexes any stored version not yet indexed, so the record covers every
|
|
90
|
+
* version the vertical holds (#2292). A record that cannot be read refuses (503). None, with no
|
|
91
|
+
* read, where nothing can hold a store: no vertical, or no driver registered.
|
|
92
|
+
*/
|
|
93
|
+
async function declaredRecordOf(admin, actor, scope, drivers) {
|
|
83
94
|
if (!scope.vertical || !mayHoldScopeStores(undefined, drivers))
|
|
84
95
|
return [];
|
|
96
|
+
return storesDeclaredBy(admin, actor, scope.vertical);
|
|
97
|
+
}
|
|
98
|
+
/** The running version's stores, for a scope whose vertical has declared at least one (the record says so). */
|
|
99
|
+
async function runningStoresOfDeclaring(admin, actor, scope, drivers, running) {
|
|
100
|
+
const vertical = scope.vertical;
|
|
85
101
|
const { versionId } = running ?? await runningVersionOf(admin, actor, scope);
|
|
86
102
|
const manifestJson = versionId
|
|
87
|
-
? await admin.versionManifest(actor,
|
|
103
|
+
? await admin.versionManifest(actor, vertical, versionId).catch(() => undefined)
|
|
88
104
|
: undefined;
|
|
89
105
|
if (!mayHoldScopeStores(manifestJson, drivers))
|
|
90
106
|
return [];
|
|
91
107
|
const declared = typeof manifestJson === 'string' && scopeStoresDeclaredIn(manifestJson) ? declaredStoresOf(manifestJson) : [];
|
|
92
108
|
if (declared.length > 0)
|
|
93
109
|
return declared;
|
|
94
|
-
if ((await storesDeclaredBy(admin, actor, scope.vertical)).length === 0)
|
|
95
|
-
return [];
|
|
96
110
|
throw new ControlPlaneError(503, `scope ${scope.id} may hold scope stores, and the version it runs cannot say which`);
|
|
97
111
|
}
|
|
98
112
|
/** Every binding any version of `vertical` declared (#2286); a record that cannot be read refuses (503). */
|
|
@@ -109,18 +123,19 @@ async function storesDeclaredBy(admin, actor, vertical) {
|
|
|
109
123
|
* (`runningVersionStoresOf`, which still fails closed first), and every binding any version of its
|
|
110
124
|
* vertical has declared (`HostAdmin.verticalScopeStores`, read by its keys). Export, wipe, erasure
|
|
111
125
|
* and the meter reach `all`, so an object under a binding a later version dropped is still
|
|
112
|
-
* reached. A restore imports only into `running` (L8).
|
|
126
|
+
* reached. A restore imports only into `running` (L8). The record is read once, first, and judges
|
|
127
|
+
* both.
|
|
113
128
|
*
|
|
114
129
|
* A binding keeps its driver for the life of the vertical, since publish refuses a driver change; a
|
|
115
130
|
* running version whose driver disagrees with the record, or a record that cannot be read,
|
|
116
131
|
* refuses (503).
|
|
117
132
|
*/
|
|
118
133
|
export async function scopeStoreReachOf(admin, actor, scope, drivers = STORE_DRIVERS, running) {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
return { running:
|
|
122
|
-
const
|
|
123
|
-
const all = new Map(
|
|
134
|
+
const record = await declaredRecordOf(admin, actor, scope, drivers);
|
|
135
|
+
if (record.length === 0)
|
|
136
|
+
return { running: [], all: [] };
|
|
137
|
+
const current = await runningStoresOfDeclaring(admin, actor, scope, drivers, running);
|
|
138
|
+
const all = new Map(record.map((store) => [store.binding, store.driver]));
|
|
124
139
|
for (const store of current) {
|
|
125
140
|
const driver = all.get(store.binding);
|
|
126
141
|
if (driver !== undefined && driver !== store.driver) {
|
|
@@ -159,6 +174,11 @@ export async function callPlatformScopeStoreAt(dispatch, target, operation, inpu
|
|
|
159
174
|
try {
|
|
160
175
|
return await callReservedStoreOperation(async (request) => {
|
|
161
176
|
const body = JSON.stringify({ ...request, objectName });
|
|
177
|
+
// The store entry refuses a body over the limit (#2811), so it is refused here, before it is signed.
|
|
178
|
+
const bytes = scopeStoreBodyBytes(body);
|
|
179
|
+
if (bytes > STORE_CALL_MAX_BYTES) {
|
|
180
|
+
throw new ControlPlaneError(413, `scope store call '${request.operation}' carries a body of ${bytes} bytes, over the limit of ${STORE_CALL_MAX_BYTES}. Nothing was sent.`);
|
|
181
|
+
}
|
|
162
182
|
let issued;
|
|
163
183
|
try {
|
|
164
184
|
issued = await issueScopeStoreEnvelope({
|
|
@@ -198,21 +218,24 @@ export async function callPlatformScopeStoreAt(dispatch, target, operation, inpu
|
|
|
198
218
|
asControlPlane(error);
|
|
199
219
|
}
|
|
200
220
|
}
|
|
201
|
-
/** The
|
|
221
|
+
/** The reserved operations a platform call makes, a dump in pages (D-69), one chain per declared binding. */
|
|
202
222
|
export function platformScopeStoresFor(dispatch) {
|
|
203
223
|
const call = (target, operation, input) => callPlatformScopeStore(dispatch, target, operation, input);
|
|
224
|
+
const reservedAt = (target) => ((operation, input) => call(target, operation, input));
|
|
204
225
|
return {
|
|
205
226
|
async exportStores({ tenantId, scopeId, vertical, stores }) {
|
|
206
227
|
const exported = [];
|
|
207
228
|
for (const store of stores) {
|
|
208
|
-
const
|
|
229
|
+
const target = { ...store, tenantId, scopeId, vertical };
|
|
230
|
+
const tables = await readStoreDump(reservedAt(target), storeDriverDumpPageBytes(store.driver));
|
|
209
231
|
exported.push({ binding: store.binding, driver: store.driver, tables });
|
|
210
232
|
}
|
|
211
233
|
return exported;
|
|
212
234
|
},
|
|
213
235
|
async importStores({ tenantId, scopeId, vertical, stores }) {
|
|
214
236
|
for (const store of stores) {
|
|
215
|
-
|
|
237
|
+
const target = { ...store, tenantId, scopeId, vertical };
|
|
238
|
+
await writeStoreDump(reservedAt(target), store.tables, storeDriverDumpPageBytes(store.driver));
|
|
216
239
|
}
|
|
217
240
|
},
|
|
218
241
|
async eraseSubject({ tenantId, scopeId, vertical, subjectId, stores }) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform-scope-stores.js","sourceRoot":"","sources":["../src/platform-scope-stores.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,kBAAkB,EAClB,qBAAqB,EACrB,aAAa,EACb,uBAAuB,EACvB,2BAA2B,EAC3B,4BAA4B,EAC5B,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,gBAAgB,EAChB,
|
|
1
|
+
{"version":3,"file":"platform-scope-stores.js","sourceRoot":"","sources":["../src/platform-scope-stores.ts"],"names":[],"mappings":"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"}
|
package/dist/r2-backups.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare function createR2DirectoryBackupStore(bucket: unknown): Directory
|
|
|
20
20
|
* *"Iceberg is the contract, the query engine is replaceable"*, and the same
|
|
21
21
|
* paragraph leaves R2 SQL's fitness explicitly open pending a benchmark. Writing
|
|
22
22
|
* partitioned NDJSON to a bucket the platform already operates gets the events out
|
|
23
|
-
* of the scope — which is what
|
|
23
|
+
* of the scope — which is what licenses the retention prune that bounds the outbox — without
|
|
24
24
|
* committing the ingest path to a product decision nobody has made yet. A
|
|
25
25
|
* compaction step turns these objects into Iceberg later, and the seam means the
|
|
26
26
|
* drain never learns which happened.
|
|
@@ -72,10 +72,13 @@ interface PruneOptions {
|
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
74
|
* Drop `scopes/…` reap copies (#493) older than the window. A copy's age is its
|
|
75
|
-
* `capturedAt
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* retention.
|
|
75
|
+
* `capturedAt`: the object's metadata, else the key's segment after the scope, which is the
|
|
76
|
+
* manifest's name and the directory a paged copy's entries sit in (#2839). So a hand-uploaded
|
|
77
|
+
* object without metadata still ages out rather than living forever by omission. An
|
|
78
|
+
* unparsable timestamp keeps the copy: deleting what cannot be dated is not retention.
|
|
79
|
+
*
|
|
80
|
+
* A copy's manifest sorts before its entries (`.` before `/`), so it goes first: a pass cut
|
|
81
|
+
* short leaves entries no backup names, for the next pass, never a backup missing its entries.
|
|
79
82
|
*
|
|
80
83
|
* Note the interaction with #37 (erasure): per-subject payloads inside these dumps are
|
|
81
84
|
* sealed under per-subject DEKs, so an old copy's PII exposure is bounded by key
|
package/dist/r2-backups.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"r2-backups.d.ts","sourceRoot":"","sources":["../src/r2-backups.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"r2-backups.d.ts","sourceRoot":"","sources":["../src/r2-backups.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrE,OAAO,KAAK,EAAE,oBAAoB,EAAoB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAoE7F,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,CAqHrE;AAoED;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,OAAO,GAAG,oBAAoB,CAsHlF;AAyCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,CA6C5D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,CAgCpE;AAED;;;;;;;;;;;;;;;GAeG;AACH,UAAU,YAAY;IACpB;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAuBD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAa/F;AAiBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
|
package/dist/r2-backups.js
CHANGED
|
@@ -9,55 +9,101 @@
|
|
|
9
9
|
* One bucket, held by the PLATFORM, not by the tenant. A tenant-held bucket would be
|
|
10
10
|
* deleted by the very teardown these copies exist to survive.
|
|
11
11
|
*/
|
|
12
|
-
import { directoryDump, scopeDump, } from '@substrat-run/contracts';
|
|
12
|
+
import { directoryDump, mergeDumpPages, scopeDump, scopeDumpChange, scopeDumpPageTable, scopeDumpTable, z, } from '@substrat-run/contracts';
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* A backup is `scopes/<tenantId>/<scopeId>/<capturedAt>.json`, so a scope's copies are one
|
|
15
15
|
* prefix list and a tenant's are one level up. The scheme is private to this module —
|
|
16
16
|
* callers address a backup by (tenant, scope, capturedAt) and never build a path.
|
|
17
|
+
*
|
|
18
|
+
* That object is the backup's MANIFEST (#2839): the dump's header and how many entries hold its
|
|
19
|
+
* tables, each entry its own object at `scopes/<tenantId>/<scopeId>/<capturedAt>/<stageId>/<seq>.json`.
|
|
20
|
+
* One object per page rather than one multipart object, because a restore reads it back a page at
|
|
21
|
+
* a time: a single JSON document cannot be read in parts. The manifest is written last, so a
|
|
22
|
+
* backup that did not finish has none and is never listed or read.
|
|
23
|
+
*
|
|
24
|
+
* A backup written before #2839 is one whole dump at the manifest's key. It is read as a backup
|
|
25
|
+
* of one entry, which is its tables.
|
|
17
26
|
*/
|
|
18
27
|
function backupKey(tenantId, scopeId, capturedAt) {
|
|
19
28
|
return `scopes/${tenantId}/${scopeId}/${capturedAt}.json`;
|
|
20
29
|
}
|
|
30
|
+
function entryKey(tenantId, scopeId, capturedAt, stageId, seq) {
|
|
31
|
+
return `scopes/${tenantId}/${scopeId}/${capturedAt}/${stageId}/${seq}.json`;
|
|
32
|
+
}
|
|
21
33
|
function scopePrefix(tenantId, scopeId) {
|
|
22
34
|
return `scopes/${tenantId}/${scopeId}/`;
|
|
23
35
|
}
|
|
36
|
+
/** A backup's manifest: the dump without its tables, and where its entries are. */
|
|
37
|
+
const backupManifest = scopeDump.omit({ tables: true }).extend({
|
|
38
|
+
format: z.literal('paged'),
|
|
39
|
+
stageId: z.string().min(1),
|
|
40
|
+
entries: z.number().int().positive(),
|
|
41
|
+
tables: z.number().int().nonnegative(),
|
|
42
|
+
});
|
|
43
|
+
const backupEntry = z.discriminatedUnion('kind', [
|
|
44
|
+
z.object({ kind: z.literal('page'), tables: z.array(scopeDumpPageTable) }),
|
|
45
|
+
z.object({ kind: z.literal('changes'), tables: z.array(scopeDumpChange) }),
|
|
46
|
+
]);
|
|
47
|
+
/** Byte length, not string length: a size is the stored object's, and non-ASCII would under-report. */
|
|
48
|
+
const byteLength = (body) => new TextEncoder().encode(body).length;
|
|
24
49
|
export function createR2BackupStore(bucket) {
|
|
25
50
|
const r2 = bucket;
|
|
26
51
|
return {
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 } : {}),
|
|
52
|
+
begin({ vertical, header }) {
|
|
53
|
+
const { tenantId, scopeId, capturedAt } = header;
|
|
54
|
+
const written = new Map();
|
|
55
|
+
return {
|
|
56
|
+
async put(stageId, seq, entry) {
|
|
57
|
+
const body = JSON.stringify(entry);
|
|
58
|
+
await r2.put(entryKey(tenantId, scopeId, capturedAt, stageId, seq), body, {
|
|
59
|
+
httpMetadata: { contentType: 'application/json' },
|
|
60
|
+
// Retention dates every object of a backup by this, entries included.
|
|
61
|
+
customMetadata: { capturedAt },
|
|
62
|
+
});
|
|
63
|
+
const sizes = written.get(stageId) ?? [];
|
|
64
|
+
sizes[seq] = byteLength(body);
|
|
65
|
+
written.set(stageId, sizes);
|
|
50
66
|
},
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
async discard(stageId) {
|
|
68
|
+
const sizes = written.get(stageId) ?? [];
|
|
69
|
+
written.delete(stageId);
|
|
70
|
+
await Promise.all(sizes.map((_, seq) => r2.delete(entryKey(tenantId, scopeId, capturedAt, stageId, seq)).catch(() => undefined)));
|
|
71
|
+
},
|
|
72
|
+
async commit({ stageId, entries, tables }) {
|
|
73
|
+
const sizes = written.get(stageId) ?? [];
|
|
74
|
+
for (let seq = 0; seq < entries; seq++) {
|
|
75
|
+
if (sizes[seq] === undefined)
|
|
76
|
+
throw new Error(`backup entry ${seq} of ${entries} was never written`);
|
|
77
|
+
}
|
|
78
|
+
const body = JSON.stringify({ ...header, format: 'paged', stageId, entries, tables });
|
|
79
|
+
const size = byteLength(body) + sizes.slice(0, entries).reduce((a, b) => a + b, 0);
|
|
80
|
+
const backup = { tenantId, scopeId, vertical, capturedAt, size, tables };
|
|
81
|
+
await r2.put(backupKey(tenantId, scopeId, capturedAt), body, {
|
|
82
|
+
httpMetadata: { contentType: 'application/json' },
|
|
83
|
+
// Mirrored into custom metadata so `list` can answer without fetching every
|
|
84
|
+
// manifest. `size` too: the manifest is the smallest part of the copy.
|
|
85
|
+
customMetadata: {
|
|
86
|
+
tenantId,
|
|
87
|
+
scopeId,
|
|
88
|
+
capturedAt,
|
|
89
|
+
tables: String(tables),
|
|
90
|
+
size: String(size),
|
|
91
|
+
...(vertical ? { vertical } : {}),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return backup;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
53
97
|
},
|
|
54
98
|
async list({ tenantId, scopeId }) {
|
|
55
99
|
const prefix = scopePrefix(tenantId, scopeId);
|
|
56
100
|
const out = [];
|
|
57
101
|
let cursor;
|
|
58
102
|
do {
|
|
103
|
+
// One level: a backup's entries sit under a directory of their own, and are not backups.
|
|
59
104
|
const page = await r2.list({
|
|
60
105
|
prefix,
|
|
106
|
+
delimiter: '/',
|
|
61
107
|
include: ['customMetadata'],
|
|
62
108
|
...(cursor ? { cursor } : {}),
|
|
63
109
|
});
|
|
@@ -71,7 +117,7 @@ export function createR2BackupStore(bucket) {
|
|
|
71
117
|
scopeId,
|
|
72
118
|
vertical: meta.vertical ?? null,
|
|
73
119
|
capturedAt,
|
|
74
|
-
size: o.size,
|
|
120
|
+
size: meta.size ? Number(meta.size) : o.size,
|
|
75
121
|
tables: meta.tables ? Number(meta.tables) : 0,
|
|
76
122
|
});
|
|
77
123
|
}
|
|
@@ -86,8 +132,32 @@ export function createR2BackupStore(bucket) {
|
|
|
86
132
|
if (!obj)
|
|
87
133
|
return null;
|
|
88
134
|
// 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
|
-
|
|
135
|
+
// fails HERE, loudly, rather than at the restore that trusted it. A manifest says so,
|
|
136
|
+
// so a damaged one is refused as a manifest rather than as a whole dump.
|
|
137
|
+
const raw = JSON.parse(await obj.text());
|
|
138
|
+
if (raw?.format !== 'paged') {
|
|
139
|
+
const { tables, ...header } = scopeDump.parse(raw);
|
|
140
|
+
return {
|
|
141
|
+
header,
|
|
142
|
+
entries: 1,
|
|
143
|
+
entry: async (index) => {
|
|
144
|
+
if (index !== 0)
|
|
145
|
+
throw new Error(`backup ${capturedAt} of scope ${scopeId} has one entry`);
|
|
146
|
+
return { kind: 'page', tables };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const { format: _, stageId, entries, tables: __, ...header } = backupManifest.parse(raw);
|
|
151
|
+
return {
|
|
152
|
+
header,
|
|
153
|
+
entries,
|
|
154
|
+
entry: async (index) => {
|
|
155
|
+
const part = await r2.get(entryKey(tenantId, scopeId, capturedAt, stageId, index));
|
|
156
|
+
if (!part)
|
|
157
|
+
throw new Error(`backup ${capturedAt} of scope ${scopeId} is missing entry ${index}`);
|
|
158
|
+
return backupEntry.parse(JSON.parse(await part.text()));
|
|
159
|
+
},
|
|
160
|
+
};
|
|
91
161
|
},
|
|
92
162
|
};
|
|
93
163
|
}
|
|
@@ -96,11 +166,61 @@ export function createR2BackupStore(bucket) {
|
|
|
96
166
|
* directory per deployment and `capturedAt` is a copy's whole address. Sharing a bucket
|
|
97
167
|
* with the scope backups above is safe by construction: `scopes/` and `directory/` cannot
|
|
98
168
|
* collide, and neither prefix is ever built by a caller.
|
|
169
|
+
*
|
|
170
|
+
* That object is the copy's MANIFEST (#2868), as a scope backup's is (#2839): its capture time
|
|
171
|
+
* and how many pages hold its tables, each page its own object at
|
|
172
|
+
* `directory/<capturedAt>/<stageId>/<seq>.json`. The manifest is written last, so a copy that did
|
|
173
|
+
* not finish has none and is never listed or read. A copy written before #2868 is one whole dump
|
|
174
|
+
* at the manifest's key, and still reads.
|
|
99
175
|
*/
|
|
100
176
|
function directoryKey(capturedAt) {
|
|
101
177
|
return `directory/${capturedAt}.json`;
|
|
102
178
|
}
|
|
179
|
+
function directoryPageKey(capturedAt, stageId, seq) {
|
|
180
|
+
return `directory/${capturedAt}/${stageId}/${seq}.json`;
|
|
181
|
+
}
|
|
103
182
|
const DIRECTORY_PREFIX = 'directory/';
|
|
183
|
+
/**
|
|
184
|
+
* How old a copy's pages must be, with no manifest, before a commit sweeps them (#2868). A copy cut
|
|
185
|
+
* off mid-write (its invocation's limits, an eviction) never reaches `discard`, and a pageful
|
|
186
|
+
* of the directory each time would otherwise stay forever. A day, so a copy still being written
|
|
187
|
+
* beside this one, the manual "back up now" racing the cron, is never one of them.
|
|
188
|
+
*/
|
|
189
|
+
const ABANDONED_PAGES_MS = 24 * 3_600_000;
|
|
190
|
+
/** Outside `directory/`, so it is never listed as a copy: the newest started copy (#2868). */
|
|
191
|
+
const DIRECTORY_ATTEMPT_KEY = 'directory-attempt.json';
|
|
192
|
+
const directoryManifest = z.object({
|
|
193
|
+
capturedAt: z.string().min(1),
|
|
194
|
+
format: z.literal('paged'),
|
|
195
|
+
stageId: z.string().min(1),
|
|
196
|
+
entries: z.number().int().positive(),
|
|
197
|
+
tables: z.number().int().nonnegative(),
|
|
198
|
+
});
|
|
199
|
+
const directoryPage = z.object({ tables: z.array(scopeDumpTable) });
|
|
200
|
+
/**
|
|
201
|
+
* Delete the pages of every copy captured before `before` that has no manifest: copies cut off
|
|
202
|
+
* before their commit (#2868). Read with no delimiter, so every key under `directory/` is seen:
|
|
203
|
+
* thirty copies of a few dozen pages each is a list call or two, once a day.
|
|
204
|
+
*/
|
|
205
|
+
async function sweepAbandonedPages(r2, before) {
|
|
206
|
+
const manifests = new Set();
|
|
207
|
+
const pages = [];
|
|
208
|
+
let cursor;
|
|
209
|
+
do {
|
|
210
|
+
const page = await r2.list({ prefix: DIRECTORY_PREFIX, ...(cursor ? { cursor } : {}) });
|
|
211
|
+
for (const { key } of page.objects) {
|
|
212
|
+
const rest = key.slice(DIRECTORY_PREFIX.length);
|
|
213
|
+
const slash = rest.indexOf('/');
|
|
214
|
+
if (slash < 0)
|
|
215
|
+
manifests.add(rest.replace(/\.json$/, ''));
|
|
216
|
+
else
|
|
217
|
+
pages.push({ key, capturedAt: rest.slice(0, slash) });
|
|
218
|
+
}
|
|
219
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
220
|
+
} while (cursor);
|
|
221
|
+
const abandoned = pages.filter((p) => p.capturedAt < before && !manifests.has(p.capturedAt));
|
|
222
|
+
await Promise.all(abandoned.map((p) => r2.delete(p.key).catch(() => undefined)));
|
|
223
|
+
}
|
|
104
224
|
/**
|
|
105
225
|
* The Cloudflare R2 implementation of `DirectoryBackupStore` (#40).
|
|
106
226
|
*
|
|
@@ -113,32 +233,53 @@ const DIRECTORY_PREFIX = 'directory/';
|
|
|
113
233
|
export function createR2DirectoryBackupStore(bucket) {
|
|
114
234
|
const r2 = bucket;
|
|
115
235
|
return {
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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),
|
|
236
|
+
begin({ capturedAt }) {
|
|
237
|
+
const stageId = crypto.randomUUID();
|
|
238
|
+
const sizes = [];
|
|
239
|
+
return {
|
|
240
|
+
async put(seq, tables) {
|
|
241
|
+
const body = JSON.stringify({ tables });
|
|
242
|
+
await r2.put(directoryPageKey(capturedAt, stageId, seq), body, {
|
|
243
|
+
httpMetadata: { contentType: 'application/json' },
|
|
244
|
+
});
|
|
245
|
+
sizes[seq] = byteLength(body);
|
|
132
246
|
},
|
|
133
|
-
|
|
134
|
-
|
|
247
|
+
async discard() {
|
|
248
|
+
await Promise.all(sizes.map((_, seq) => r2.delete(directoryPageKey(capturedAt, stageId, seq)).catch(() => undefined)));
|
|
249
|
+
},
|
|
250
|
+
async commit({ entries, tables }) {
|
|
251
|
+
for (let seq = 0; seq < entries; seq++) {
|
|
252
|
+
if (sizes[seq] === undefined)
|
|
253
|
+
throw new Error(`directory backup page ${seq} of ${entries} was never written`);
|
|
254
|
+
}
|
|
255
|
+
const body = JSON.stringify({ capturedAt, format: 'paged', stageId, entries, tables });
|
|
256
|
+
const size = byteLength(body) + sizes.slice(0, entries).reduce((a, b) => a + b, 0);
|
|
257
|
+
await r2.put(directoryKey(capturedAt), body, {
|
|
258
|
+
httpMetadata: { contentType: 'application/json' },
|
|
259
|
+
// Mirrored into custom metadata so `list` answers without fetching every manifest.
|
|
260
|
+
// `size` too: the manifest is the smallest part of the copy.
|
|
261
|
+
customMetadata: { capturedAt, tables: String(tables), size: String(size) },
|
|
262
|
+
});
|
|
263
|
+
// Best-effort, and after the manifest: the copy is committed now, and a failed sweep
|
|
264
|
+
// must not reject the commit, or the caller's `discard` would delete a listed copy's pages.
|
|
265
|
+
try {
|
|
266
|
+
await sweepAbandonedPages(r2, new Date(Date.parse(capturedAt) - ABANDONED_PAGES_MS).toISOString());
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error('directory-backup: sweeping abandoned pages failed; the next commit tries again', err instanceof Error ? err.message : String(err));
|
|
270
|
+
}
|
|
271
|
+
return { capturedAt, size, tables };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
135
274
|
},
|
|
136
275
|
async list() {
|
|
137
276
|
const out = [];
|
|
138
277
|
let cursor;
|
|
139
278
|
do {
|
|
279
|
+
// One level: a copy's pages sit under a directory of their own, and are not copies.
|
|
140
280
|
const page = await r2.list({
|
|
141
281
|
prefix: DIRECTORY_PREFIX,
|
|
282
|
+
delimiter: '/',
|
|
142
283
|
include: ['customMetadata'],
|
|
143
284
|
...(cursor ? { cursor } : {}),
|
|
144
285
|
});
|
|
@@ -149,7 +290,7 @@ export function createR2DirectoryBackupStore(bucket) {
|
|
|
149
290
|
const capturedAt = meta.capturedAt ?? o.key.slice(DIRECTORY_PREFIX.length).replace(/\.json$/, '');
|
|
150
291
|
out.push({
|
|
151
292
|
capturedAt,
|
|
152
|
-
size: o.size,
|
|
293
|
+
size: meta.size ? Number(meta.size) : o.size,
|
|
153
294
|
tables: meta.tables ? Number(meta.tables) : 0,
|
|
154
295
|
});
|
|
155
296
|
}
|
|
@@ -165,10 +306,42 @@ export function createR2DirectoryBackupStore(bucket) {
|
|
|
165
306
|
return null;
|
|
166
307
|
// Parsed through the contract on the way out, so a corrupted object fails HERE
|
|
167
308
|
// rather than half-way through the restore that trusted it.
|
|
168
|
-
|
|
309
|
+
const raw = JSON.parse(await obj.text());
|
|
310
|
+
if (raw?.format !== 'paged')
|
|
311
|
+
return directoryDump.parse(raw);
|
|
312
|
+
const manifest = directoryManifest.parse(raw);
|
|
313
|
+
const pages = [];
|
|
314
|
+
for (let seq = 0; seq < manifest.entries; seq++) {
|
|
315
|
+
const part = await r2.get(directoryPageKey(capturedAt, manifest.stageId, seq));
|
|
316
|
+
if (!part)
|
|
317
|
+
throw new Error(`directory backup ${capturedAt} is missing page ${seq}`);
|
|
318
|
+
pages.push(directoryPage.parse(JSON.parse(await part.text())).tables);
|
|
319
|
+
}
|
|
320
|
+
// `restoreDirectory` takes one dump, so the pages are joined here (a paged restore is #2868's follow-up).
|
|
321
|
+
return { capturedAt, tables: mergeDumpPages(pages) };
|
|
169
322
|
},
|
|
170
323
|
async delete({ capturedAt }) {
|
|
324
|
+
// The manifest first: once it is gone the copy is no longer listed, whatever of its pages remain.
|
|
171
325
|
await r2.delete(directoryKey(capturedAt));
|
|
326
|
+
const prefix = `${DIRECTORY_PREFIX}${capturedAt}/`;
|
|
327
|
+
let cursor;
|
|
328
|
+
do {
|
|
329
|
+
const page = await r2.list({ prefix, ...(cursor ? { cursor } : {}) });
|
|
330
|
+
await Promise.all(page.objects.map((o) => r2.delete(o.key)));
|
|
331
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
332
|
+
} while (cursor);
|
|
333
|
+
},
|
|
334
|
+
async markAttempt({ attemptedAt }) {
|
|
335
|
+
await r2.put(DIRECTORY_ATTEMPT_KEY, JSON.stringify({ attemptedAt }), {
|
|
336
|
+
httpMetadata: { contentType: 'application/json' },
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
async lastAttempt() {
|
|
340
|
+
const obj = await r2.get(DIRECTORY_ATTEMPT_KEY);
|
|
341
|
+
if (!obj)
|
|
342
|
+
return null;
|
|
343
|
+
const parsed = z.object({ attemptedAt: z.string().min(1) }).safeParse(JSON.parse(await obj.text()));
|
|
344
|
+
return parsed.success ? parsed.data.attemptedAt : null;
|
|
172
345
|
},
|
|
173
346
|
};
|
|
174
347
|
}
|
|
@@ -217,7 +390,7 @@ function occurrenceDay(occurredAt) {
|
|
|
217
390
|
* *"Iceberg is the contract, the query engine is replaceable"*, and the same
|
|
218
391
|
* paragraph leaves R2 SQL's fitness explicitly open pending a benchmark. Writing
|
|
219
392
|
* partitioned NDJSON to a bucket the platform already operates gets the events out
|
|
220
|
-
* of the scope — which is what
|
|
393
|
+
* of the scope — which is what licenses the retention prune that bounds the outbox — without
|
|
221
394
|
* committing the ingest path to a product decision nobody has made yet. A
|
|
222
395
|
* compaction step turns these objects into Iceberg later, and the seam means the
|
|
223
396
|
* drain never learns which happened.
|
|
@@ -338,10 +511,13 @@ async function listAll(r2, prefix) {
|
|
|
338
511
|
const DAY_MS = 86_400_000;
|
|
339
512
|
/**
|
|
340
513
|
* Drop `scopes/…` reap copies (#493) older than the window. A copy's age is its
|
|
341
|
-
* `capturedAt
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* retention.
|
|
514
|
+
* `capturedAt`: the object's metadata, else the key's segment after the scope, which is the
|
|
515
|
+
* manifest's name and the directory a paged copy's entries sit in (#2839). So a hand-uploaded
|
|
516
|
+
* object without metadata still ages out rather than living forever by omission. An
|
|
517
|
+
* unparsable timestamp keeps the copy: deleting what cannot be dated is not retention.
|
|
518
|
+
*
|
|
519
|
+
* A copy's manifest sorts before its entries (`.` before `/`), so it goes first: a pass cut
|
|
520
|
+
* short leaves entries no backup names, for the next pass, never a backup missing its entries.
|
|
345
521
|
*
|
|
346
522
|
* Note the interaction with #37 (erasure): per-subject payloads inside these dumps are
|
|
347
523
|
* sealed under per-subject DEKs, so an old copy's PII exposure is bounded by key
|
|
@@ -352,7 +528,7 @@ export async function pruneScopeBackups(bucket, options) {
|
|
|
352
528
|
const cutoff = (options.now?.() ?? new Date()).getTime() - options.olderThanDays * DAY_MS;
|
|
353
529
|
let pruned = 0;
|
|
354
530
|
for (const o of await listAll(r2, 'scopes/')) {
|
|
355
|
-
const capturedAt = o.customMetadata?.capturedAt ??
|
|
531
|
+
const capturedAt = o.customMetadata?.capturedAt ?? (o.key.split('/')[3] ?? '').replace(/\.json$/, '');
|
|
356
532
|
const at = new Date(capturedAt).getTime();
|
|
357
533
|
if (Number.isNaN(at) || at >= cutoff)
|
|
358
534
|
continue;
|