orez-lite 0.16.6 → 0.16.7

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.
@@ -159,6 +159,10 @@ export function createNamespaceBackupManager(options) {
159
159
  const keepControlPlane = options.keepControlPlane ?? 30;
160
160
  const controlPlaneNamespace = options.controlPlaneNamespace ?? 'singleton';
161
161
  const runBudgetMs = options.runBudgetMs ?? 10 * 60 * 1000;
162
+ const scanChunkBytes = options.scanChunkBytes ?? partBytes;
163
+ const maxInflightParts = Math.max(1, options.maxInflightParts ?? 4);
164
+ const scanAttempts = Math.max(1, options.scanAttempts ?? 3);
165
+ const chunkAttempts = Math.max(1, options.chunkAttempts ?? 3);
162
166
  const excludedTables = new Set(options.excludedTables ?? []);
163
167
  const acceptedFormats = new Set([options.format, ...(options.acceptedFormats ?? [])]);
164
168
  const backupPrefix = options.prefix ?? ((namespace) => `backups/${namespace.replace(':', '/')}/`);
@@ -186,227 +190,385 @@ export function createNamespaceBackupManager(options) {
186
190
  }
187
191
  };
188
192
  const readMarker = (env, namespace) => readMarkerWith((sql, params = []) => options.query(env, namespace, sql, params));
189
- const exportNamespace = async (env, namespace) => {
190
- const startedAt = Date.now();
193
+ const encoder = new TextEncoder();
194
+ const encodeLine = (value, digested = true) => ({
195
+ bytes: encoder.encode(`${JSON.stringify(value)}\n`),
196
+ digested,
197
+ });
198
+ /**
199
+ * Run one bounded piece of the scan in its own read session, retrying it when
200
+ * a writer preempts the session.
201
+ *
202
+ * `work` re-runs from the start on a retry, so it must return everything it
203
+ * produced rather than publish it, and the caller commits that only once.
204
+ */
205
+ const readChunk = async (env, namespace, work) => {
206
+ for (let attempt = 0; attempt < chunkAttempts; attempt++) {
207
+ try {
208
+ return {
209
+ outcome: 'read',
210
+ value: await options.readSession(env, namespace, work),
211
+ };
212
+ }
213
+ catch (error) {
214
+ if (!(error instanceof ApplicationSqlSessionPreemptedError))
215
+ throw error;
216
+ }
217
+ }
218
+ return { outcome: 'preempted' };
219
+ };
220
+ /**
221
+ * Read the schema, the write marker, and the primary key of every WITHOUT
222
+ * ROWID table in one session, so the scan below knows what to page and what
223
+ * marker every one of its chunks has to agree with.
224
+ */
225
+ const readScanSchema = async (read) => {
226
+ // Read before scanning. A concurrent write then leaves the live marker
227
+ // ahead of latest.json and guarantees another backup.
228
+ const marker = await readMarkerWith(read);
229
+ const master = await read("SELECT name, sql, type, tbl_name FROM sqlite_master WHERE type IN ('table', 'index') AND sql IS NOT NULL ORDER BY name", []);
230
+ const unorderedTables = master.filter((row) => row.type === 'table' && !isExcluded(row.name));
231
+ const tableNames = unorderedTables.map((row) => String(row.name));
232
+ const tableNamesBySqlIdentity = tableIdentities(tableNames);
233
+ // sqlite_master already carries every CREATE statement in this bounded
234
+ // schema read. Derive FK edges from those statements instead of asking
235
+ // pragma_foreign_key_list to re-walk the complete schema once per table.
236
+ const dependencies = new Map(unorderedTables.map((table) => [
237
+ String(table.name),
238
+ tableDependencies(table.sql, String(table.name), tableNamesBySqlIdentity),
239
+ ]));
240
+ const orderedNames = dependencyOrder(tableNames, dependencies);
241
+ const tableByName = new Map(unorderedTables.map((table) => [String(table.name), table]));
242
+ const indexes = master.filter((row) => row.type === 'index' && !isExcluded(row.name) && !isExcluded(row.tbl_name));
243
+ const tables = [];
244
+ for (const name of orderedNames) {
245
+ const row = tableByName.get(name);
246
+ const sql = String(row.sql);
247
+ const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(sql);
248
+ const primaryKeyColumns = withoutRowid
249
+ ? (await read(`PRAGMA table_info("${quoteIdentifier(name)}")`, []))
250
+ .filter((column) => Number(column.pk) > 0)
251
+ .sort((left, right) => Number(left.pk) - Number(right.pk))
252
+ .map((column) => String(column.name))
253
+ : [];
254
+ if (withoutRowid && primaryKeyColumns.length === 0) {
255
+ throw new Error(`WITHOUT ROWID table ${name} has no primary key`);
256
+ }
257
+ tables.push({
258
+ name,
259
+ sql,
260
+ indexes: indexes
261
+ .filter((index) => index.tbl_name === name)
262
+ .map((index) => String(index.sql)),
263
+ withoutRowid,
264
+ primaryKeyColumns,
265
+ });
266
+ }
267
+ return { marker, tables };
268
+ };
269
+ /**
270
+ * Page rows until this chunk has produced `scanChunkBytes`, inside one
271
+ * session that also reads the marker.
272
+ *
273
+ * No writer is admitted while the session is open, so the marker and the rows
274
+ * describe the same committed state. A marker that no longer matches the one
275
+ * the schema read observed means a transaction landed since the scan started
276
+ * and the pages already collected are from a state that no longer exists.
277
+ */
278
+ const readScanChunk = (fenceMarker, tables, cursor) => async (read) => {
279
+ const marker = await readMarkerWith(read);
280
+ if (marker !== fenceMarker)
281
+ return { torn: true, marker };
282
+ const lines = [];
283
+ const tableRows = {};
284
+ const next = { ...cursor };
285
+ let produced = 0;
286
+ const openNextTable = () => {
287
+ next.tableIndex++;
288
+ next.tableOpened = false;
289
+ next.rowidCursor = 0;
290
+ next.primaryKeyCursor = null;
291
+ next.limit = 200;
292
+ };
293
+ while (next.tableIndex < tables.length && produced < scanChunkBytes) {
294
+ const table = tables[next.tableIndex];
295
+ if (!next.tableOpened) {
296
+ const line = encodeLine({
297
+ kind: 'table',
298
+ name: table.name,
299
+ sql: table.sql,
300
+ indexes: table.indexes,
301
+ });
302
+ lines.push(line);
303
+ produced += line.bytes.byteLength;
304
+ next.tableOpened = true;
305
+ tableRows[table.name] = tableRows[table.name] ?? 0;
306
+ }
307
+ const quotedPrimaryKey = table.primaryKeyColumns
308
+ .map((column) => `"${quoteIdentifier(column)}"`)
309
+ .join(', ');
310
+ const usedLimit = next.limit;
311
+ const rows = table.withoutRowid
312
+ ? await read(next.primaryKeyCursor
313
+ ? `SELECT * FROM "${quoteIdentifier(table.name)}" WHERE (${quotedPrimaryKey}) > (${table.primaryKeyColumns.map(() => '?').join(', ')}) ORDER BY ${quotedPrimaryKey} LIMIT ?`
314
+ : `SELECT * FROM "${quoteIdentifier(table.name)}" ORDER BY ${quotedPrimaryKey} LIMIT ?`, next.primaryKeyCursor ? [...next.primaryKeyCursor, usedLimit] : [usedLimit])
315
+ : await read(`SELECT rowid AS __orez_backup_rowid, * FROM "${quoteIdentifier(table.name)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, [next.rowidCursor, usedLimit]);
316
+ if (rows.length === 0) {
317
+ openNextTable();
318
+ continue;
319
+ }
320
+ if (table.withoutRowid) {
321
+ const last = rows.at(-1);
322
+ next.primaryKeyCursor = table.primaryKeyColumns.map((column) => last[column]);
323
+ }
324
+ else {
325
+ next.rowidCursor = rows.at(-1)?.__orez_backup_rowid;
326
+ for (const row of rows)
327
+ delete row.__orez_backup_rowid;
328
+ }
329
+ const line = encodeLine({ kind: 'rows', table: table.name, rows });
330
+ lines.push(line);
331
+ produced += line.bytes.byteLength;
332
+ tableRows[table.name] = (tableRows[table.name] ?? 0) + rows.length;
333
+ const perRow = Math.max(1, Math.ceil(line.bytes.byteLength / rows.length));
334
+ next.limit = Math.max(20, Math.min(1000, Math.floor(chunkTargetBytes / perRow)));
335
+ if (rows.length < usedLimit)
336
+ openNextTable();
337
+ }
338
+ return { torn: false, lines, tableRows, next };
339
+ };
340
+ /**
341
+ * Scan a namespace in short read sessions fenced by the write marker.
342
+ *
343
+ * A single session covering the whole scan has to stay open across every R2
344
+ * upload, and an arriving writer preempts it, so on a busy namespace the
345
+ * export cannot finish: production's control plane lost roughly twenty
346
+ * consecutive attempts that way while every quiet project namespace exported
347
+ * normally.
348
+ *
349
+ * The dump only has to be one state the database actually had, and
350
+ * `write_seq` answers that directly: it advances on every committed
351
+ * application-SQL mutation, which is the same invariant `runScheduledBackups`
352
+ * already trusts when it skips a namespace whose marker has not moved. So
353
+ * each chunk reads the marker and its pages inside one short session, and
354
+ * every chunk has to observe the marker the schema read did. Equal markers at
355
+ * both ends of a monotonic counter means no transaction committed in between,
356
+ * which is the same guarantee the single session bought, without holding the
357
+ * database across the network.
358
+ *
359
+ * Uploads happen between chunks with no session open, and are not awaited
360
+ * until the scan finishes or `maxInflightParts` are outstanding, so R2 stays
361
+ * off the fenced window entirely for a dump that fits in the in-flight bound.
362
+ */
363
+ const runScanAttempt = async (env, namespace, key, exportedAt) => {
191
364
  const files = options.files(env);
192
- const exportedAt = new Date().toISOString();
193
- const key = `${backupPrefix(namespace)}${Date.now()}.ndjson`;
194
- // One read session for the whole scan. Every page below reads the same
195
- // committed state, so the dump is a database that actually existed.
196
- const scan = await options
197
- .readSession(env, namespace, async (read) => {
198
- // Read before scanning. A concurrent write then leaves the live marker
199
- // ahead of latest.json and guarantees another backup.
200
- const marker = await readMarkerWith(read);
201
- const master = await read("SELECT name, sql, type, tbl_name FROM sqlite_master WHERE type IN ('table', 'index') AND sql IS NOT NULL ORDER BY name", []);
202
- const unorderedTables = master.filter((row) => row.type === 'table' && !isExcluded(row.name));
203
- const tableNames = unorderedTables.map((row) => String(row.name));
204
- const tableNamesBySqlIdentity = tableIdentities(tableNames);
205
- // sqlite_master already carries every CREATE statement in this bounded
206
- // schema read. Derive FK edges from those statements instead of asking
207
- // pragma_foreign_key_list to re-walk the complete schema once per table.
208
- const dependencies = new Map(unorderedTables.map((table) => [
209
- String(table.name),
210
- tableDependencies(table.sql, String(table.name), tableNamesBySqlIdentity),
211
- ]));
212
- const orderedNames = dependencyOrder(unorderedTables.map((table) => String(table.name)), dependencies);
213
- const tableByName = new Map(unorderedTables.map((table) => [String(table.name), table]));
214
- const tables = orderedNames.map((name) => tableByName.get(name));
215
- const indexes = master.filter((row) => row.type === 'index' && !isExcluded(row.name) && !isExcluded(row.tbl_name));
216
- const upload = await files.createMultipartUpload(key);
217
- const uploadedParts = [];
218
- const encoder = new TextEncoder();
219
- let chunks = [];
220
- let bufferedBytes = 0;
221
- let totalBytes = 0;
222
- const digest = sha256.create();
223
- const flushParts = async (final) => {
224
- if (!final && bufferedBytes < partBytes)
225
- return;
226
- let merged = new Uint8Array(bufferedBytes);
227
- let offset = 0;
228
- for (const chunk of chunks) {
229
- merged.set(chunk, offset);
230
- offset += chunk.byteLength;
365
+ const schemaChunk = await readChunk(env, namespace, readScanSchema);
366
+ if (schemaChunk.outcome === 'preempted')
367
+ return { outcome: 'preempted' };
368
+ const { marker, tables } = schemaChunk.value;
369
+ const upload = await files.createMultipartUpload(key);
370
+ const partUploads = [];
371
+ let chunks = [];
372
+ let bufferedBytes = 0;
373
+ let totalBytes = 0;
374
+ let rowTotal = 0;
375
+ const tableRows = {};
376
+ const digest = sha256.create();
377
+ // an abort that races an in-flight uploadPart can leave the part behind, and
378
+ // this bucket already carries thousands of orphans. settle first, always.
379
+ const abortUpload = async () => {
380
+ await Promise.allSettled(partUploads);
381
+ await upload.abort().catch(() => { });
382
+ };
383
+ const sendPart = async (value) => {
384
+ const pending = upload.uploadPart(partUploads.length + 1, value);
385
+ // Nothing awaits this until the scan is done, so keep workerd from
386
+ // reporting a rejection that the final Promise.all will surface anyway.
387
+ void pending.catch(() => { });
388
+ partUploads.push(pending);
389
+ const bound = partUploads.length - maxInflightParts;
390
+ if (bound >= 0)
391
+ await partUploads[bound];
392
+ };
393
+ const flushParts = async (final) => {
394
+ if (!final && bufferedBytes < partBytes)
395
+ return;
396
+ let merged = new Uint8Array(bufferedBytes);
397
+ let offset = 0;
398
+ for (const chunk of chunks) {
399
+ merged.set(chunk, offset);
400
+ offset += chunk.byteLength;
401
+ }
402
+ while (merged.byteLength >= partBytes) {
403
+ await sendPart(merged.slice(0, partBytes));
404
+ merged = merged.slice(partBytes);
405
+ }
406
+ if (final && (merged.byteLength > 0 || partUploads.length === 0)) {
407
+ await sendPart(merged);
408
+ merged = new Uint8Array(0);
409
+ }
410
+ chunks = merged.byteLength ? [merged] : [];
411
+ bufferedBytes = merged.byteLength;
412
+ };
413
+ const appendLine = (line) => {
414
+ if (line.digested)
415
+ digest.update(line.bytes);
416
+ chunks.push(line.bytes);
417
+ bufferedBytes += line.bytes.byteLength;
418
+ totalBytes += line.bytes.byteLength;
419
+ };
420
+ try {
421
+ appendLine(encodeLine({
422
+ kind: 'header',
423
+ format: options.format,
424
+ integrity: 'sha256',
425
+ ns: namespace,
426
+ exportedAt,
427
+ marker,
428
+ orderedTables: true,
429
+ }));
430
+ let cursor = {
431
+ tableIndex: 0,
432
+ tableOpened: false,
433
+ rowidCursor: 0,
434
+ primaryKeyCursor: null,
435
+ limit: 200,
436
+ };
437
+ while (cursor.tableIndex < tables.length) {
438
+ const chunk = await readChunk(env, namespace, readScanChunk(marker, tables, cursor));
439
+ if (chunk.outcome === 'preempted') {
440
+ await abortUpload();
441
+ return { outcome: 'preempted' };
231
442
  }
232
- while (merged.byteLength >= partBytes) {
233
- uploadedParts.push(await upload.uploadPart(uploadedParts.length + 1, merged.slice(0, partBytes)));
234
- merged = merged.slice(partBytes);
443
+ if (chunk.value.torn) {
444
+ await abortUpload();
445
+ return { outcome: 'torn', marker, observed: chunk.value.marker };
235
446
  }
236
- if (final && (merged.byteLength > 0 || uploadedParts.length === 0)) {
237
- uploadedParts.push(await upload.uploadPart(uploadedParts.length + 1, merged));
238
- merged = new Uint8Array(0);
447
+ for (const line of chunk.value.lines)
448
+ appendLine(line);
449
+ for (const [table, count] of Object.entries(chunk.value.tableRows)) {
450
+ tableRows[table] = (tableRows[table] ?? 0) + count;
451
+ rowTotal += count;
239
452
  }
240
- chunks = merged.byteLength ? [merged] : [];
241
- bufferedBytes = merged.byteLength;
242
- };
243
- const writeLine = async (value, includeInDigest = true) => {
244
- const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
245
- if (includeInDigest)
246
- digest.update(bytes);
247
- chunks.push(bytes);
248
- bufferedBytes += bytes.byteLength;
249
- totalBytes += bytes.byteLength;
453
+ cursor = chunk.value.next;
250
454
  await flushParts(false);
251
- return bytes.byteLength;
252
- };
253
- let rowTotal = 0;
254
- const tableRows = {};
455
+ }
456
+ appendLine(encodeLine({
457
+ kind: 'footer',
458
+ tables: tables.length,
459
+ rows: rowTotal,
460
+ sha256: hex(digest.digest()),
461
+ }, false));
462
+ await flushParts(true);
463
+ await upload.complete(await Promise.all(partUploads));
464
+ }
465
+ catch (error) {
466
+ await abortUpload();
467
+ throw error;
468
+ }
469
+ return {
470
+ outcome: 'scanned',
471
+ marker,
472
+ tables: tables.length,
473
+ rows: rowTotal,
474
+ tableRows,
475
+ bytes: totalBytes,
476
+ parts: partUploads.length,
477
+ };
478
+ };
479
+ const exportNamespace = async (env, namespace) => {
480
+ const startedAt = Date.now();
481
+ const files = options.files(env);
482
+ let torn = 0;
483
+ for (let attempt = 0; attempt < scanAttempts; attempt++) {
484
+ const exportedAt = new Date().toISOString();
485
+ const key = `${backupPrefix(namespace)}${Date.now()}.ndjson`;
486
+ let scan;
255
487
  try {
256
- await writeLine({
257
- kind: 'header',
258
- format: options.format,
259
- integrity: 'sha256',
260
- ns: namespace,
261
- exportedAt,
262
- marker,
263
- orderedTables: true,
264
- });
265
- for (const table of tables) {
266
- const name = String(table.name);
267
- const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(String(table.sql));
268
- const primaryKeyColumns = withoutRowid
269
- ? (await read(`PRAGMA table_info("${quoteIdentifier(name)}")`, []))
270
- .filter((column) => Number(column.pk) > 0)
271
- .sort((left, right) => Number(left.pk) - Number(right.pk))
272
- .map((column) => String(column.name))
273
- : [];
274
- if (withoutRowid && primaryKeyColumns.length === 0) {
275
- throw new Error(`WITHOUT ROWID table ${name} has no primary key`);
276
- }
277
- const quotedPrimaryKey = primaryKeyColumns
278
- .map((column) => `"${quoteIdentifier(column)}"`)
279
- .join(', ');
280
- let tableRowTotal = 0;
281
- await writeLine({
282
- kind: 'table',
283
- name,
284
- sql: table.sql,
285
- indexes: indexes
286
- .filter((index) => index.tbl_name === name)
287
- .map((index) => index.sql),
288
- });
289
- let rowidCursor = 0;
290
- let primaryKeyCursor = null;
291
- let limit = 200;
292
- while (true) {
293
- const usedLimit = limit;
294
- const rows = withoutRowid
295
- ? await read(primaryKeyCursor
296
- ? `SELECT * FROM "${quoteIdentifier(name)}" WHERE (${quotedPrimaryKey}) > (${primaryKeyColumns.map(() => '?').join(', ')}) ORDER BY ${quotedPrimaryKey} LIMIT ?`
297
- : `SELECT * FROM "${quoteIdentifier(name)}" ORDER BY ${quotedPrimaryKey} LIMIT ?`, primaryKeyCursor ? [...primaryKeyCursor, usedLimit] : [usedLimit])
298
- : await read(`SELECT rowid AS __orez_backup_rowid, * FROM "${quoteIdentifier(name)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, [rowidCursor, usedLimit]);
299
- if (rows.length === 0)
300
- break;
301
- if (withoutRowid) {
302
- const last = rows.at(-1);
303
- primaryKeyCursor = primaryKeyColumns.map((column) => last[column]);
304
- }
305
- else {
306
- rowidCursor = rows.at(-1)?.__orez_backup_rowid;
307
- for (const row of rows)
308
- delete row.__orez_backup_rowid;
309
- }
310
- const lineBytes = await writeLine({ kind: 'rows', table: name, rows });
311
- rowTotal += rows.length;
312
- tableRowTotal += rows.length;
313
- const perRow = Math.max(1, Math.ceil(lineBytes / rows.length));
314
- limit = Math.max(20, Math.min(1000, Math.floor(chunkTargetBytes / perRow)));
315
- if (rows.length < usedLimit)
316
- break;
317
- }
318
- tableRows[name] = tableRowTotal;
319
- }
320
- await writeLine({
321
- kind: 'footer',
322
- tables: tables.length,
323
- rows: rowTotal,
324
- sha256: hex(digest.digest()),
325
- }, false);
326
- await flushParts(true);
327
- await upload.complete(uploadedParts);
488
+ scan = await runScanAttempt(env, namespace, key, exportedAt);
328
489
  }
329
490
  catch (error) {
491
+ log({
492
+ phase: 'export_upload',
493
+ outcome: 'error',
494
+ namespace,
495
+ durationMs: Date.now() - startedAt,
496
+ error: errorMessage(error),
497
+ });
498
+ throw error;
499
+ }
500
+ if (scan.outcome === 'preempted') {
501
+ log({
502
+ phase: 'export',
503
+ outcome: 'preempted',
504
+ namespace,
505
+ reason: 'session_preempted',
506
+ torn,
507
+ durationMs: Date.now() - startedAt,
508
+ });
509
+ return { outcome: 'preempted', namespace };
510
+ }
511
+ if (scan.outcome === 'torn') {
512
+ torn++;
513
+ log({
514
+ phase: 'export_scan',
515
+ outcome: 'torn',
516
+ namespace,
517
+ marker: scan.marker,
518
+ observedMarker: scan.observed,
519
+ attempt: attempt + 1,
520
+ durationMs: Date.now() - startedAt,
521
+ });
522
+ continue;
523
+ }
524
+ const summary = {
525
+ ns: namespace,
526
+ key,
527
+ exportedAt,
528
+ marker: scan.marker,
529
+ tables: scan.tables,
530
+ rows: scan.rows,
531
+ tableRows: scan.tableRows,
532
+ bytes: scan.bytes,
533
+ parts: scan.parts,
534
+ };
535
+ let keepPreviousLatest = false;
536
+ if (scan.rows === 0) {
330
537
  try {
331
- await upload.abort();
538
+ const previous = await files.get(`${backupPrefix(namespace)}latest.json`);
539
+ if (previous) {
540
+ const previousSummary = (await previous.json());
541
+ keepPreviousLatest = Number(previousSummary.rows) > 0;
542
+ }
332
543
  }
333
544
  catch {
334
- // Preserve the original export failure.
545
+ // A missing/corrupt pointer must not prevent a new valid backup.
335
546
  }
336
- if (error instanceof ApplicationSqlSessionPreemptedError) {
337
- log({
338
- phase: 'export_upload',
339
- outcome: 'preempted',
340
- namespace,
341
- durationMs: Date.now() - startedAt,
342
- });
343
- }
344
- else {
345
- log({
346
- phase: 'export_upload',
347
- outcome: 'error',
348
- namespace,
349
- durationMs: Date.now() - startedAt,
350
- error: errorMessage(error),
351
- });
352
- }
353
- throw error;
354
547
  }
355
- return {
356
- marker,
357
- tables: tables.length,
358
- rows: rowTotal,
359
- tableRows,
360
- bytes: totalBytes,
361
- parts: uploadedParts.length,
362
- };
363
- })
364
- .catch((error) => {
365
- if (error instanceof ApplicationSqlSessionPreemptedError)
366
- return null;
367
- throw error;
368
- });
369
- if (scan === null) {
548
+ if (!keepPreviousLatest) {
549
+ await files.put(`${backupPrefix(namespace)}latest.json`, JSON.stringify(summary));
550
+ }
370
551
  log({
371
552
  phase: 'export',
372
- outcome: 'preempted',
553
+ outcome: 'success',
373
554
  namespace,
374
555
  durationMs: Date.now() - startedAt,
556
+ rows: summary.rows,
557
+ bytes: summary.bytes,
558
+ parts: summary.parts,
559
+ torn,
375
560
  });
376
- return { outcome: 'preempted', namespace };
377
- }
378
- const summary = {
379
- ns: namespace,
380
- key,
381
- exportedAt,
382
- ...scan,
383
- };
384
- let keepPreviousLatest = false;
385
- if (scan.rows === 0) {
386
- try {
387
- const previous = await files.get(`${backupPrefix(namespace)}latest.json`);
388
- if (previous) {
389
- const previousSummary = (await previous.json());
390
- keepPreviousLatest = Number(previousSummary.rows) > 0;
391
- }
392
- }
393
- catch {
394
- // A missing/corrupt pointer must not prevent a new valid backup.
395
- }
396
- }
397
- if (!keepPreviousLatest) {
398
- await files.put(`${backupPrefix(namespace)}latest.json`, JSON.stringify(summary));
561
+ return { outcome: 'exported', summary };
399
562
  }
400
563
  log({
401
564
  phase: 'export',
402
- outcome: 'success',
565
+ outcome: 'preempted',
403
566
  namespace,
567
+ reason: 'torn',
568
+ torn,
404
569
  durationMs: Date.now() - startedAt,
405
- rows: summary.rows,
406
- bytes: summary.bytes,
407
- parts: summary.parts,
408
570
  });
409
- return { outcome: 'exported', summary };
571
+ return { outcome: 'preempted', namespace };
410
572
  };
411
573
  const importNamespace = async (env, namespace, key, importOptions = {}) => {
412
574
  const startedAt = Date.now();
@@ -418,7 +580,6 @@ export function createNamespaceBackupManager(options) {
418
580
  let validatedFooter;
419
581
  let validatedRows = 0;
420
582
  const validationDigest = sha256.create();
421
- const encoder = new TextEncoder();
422
583
  const tableEntries = [];
423
584
  const seenTables = new Set();
424
585
  for await (const line of ndjsonLines(validationObject.body)) {