@stage5/lumine 0.2.53 → 0.2.55

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.
@@ -1,7 +1,22 @@
1
- import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ createReadStream,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readSync,
12
+ renameSync,
13
+ truncateSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
3
17
  import os from "node:os";
4
18
  import path from "node:path";
19
+ import readline from "node:readline";
5
20
 
6
21
  import { writeAdminJsonFile, readAdminJsonFile } from "./admin-news.js";
7
22
  import { requestJson } from "./http.js";
@@ -9,9 +24,14 @@ import { requestJson } from "./http.js";
9
24
  const MAX_BATCH_TARGET_FILE_BYTES = 2 * 1024 * 1024;
10
25
  const MAX_BATCH_TARGETS = 20_000;
11
26
  const MAX_AUTOMATIC_PAGES = 100_000;
12
- const MAX_ADMIN_CHECKPOINT_BYTES = 64 * 1024 * 1024;
13
- const ADMIN_PAGINATION_SCHEMA_VERSION = 2;
27
+ const MAX_LEGACY_ADMIN_CHECKPOINT_BYTES = 64 * 1024 * 1024;
28
+ const MAX_PAGINATION_CHECKPOINT_BYTES = 256 * 1024;
29
+ const MAX_BATCH_CHECKPOINT_BYTES = 64 * 1024 * 1024;
30
+ const ADMIN_PAGINATION_SCHEMA_VERSION = 3;
31
+ const LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION = 2;
14
32
  const ADMIN_BATCH_SCHEMA_VERSION = 2;
33
+ const PAGINATED_RESULT_STORAGE = Symbol("lumineAdminPaginationStorage");
34
+ const SPOOL_READ_BUFFER_BYTES = 64 * 1024;
15
35
 
16
36
  function validationError(message) {
17
37
  const error = new Error(message);
@@ -59,30 +79,522 @@ function defaultCheckpointPath({ runId, operationName }) {
59
79
  return path.join(os.tmpdir(), `lumine-admin-run-${runId}-${safeName}.json`);
60
80
  }
61
81
 
62
- function aggregatePageResult({ operation, pages, checkpointPath, state }) {
63
- const last = pages[pages.length - 1] || { ok: true, status: "success", data: {} };
82
+ function operationFingerprintValue({
83
+ schemaVersion,
84
+ options,
85
+ operation,
86
+ runId,
87
+ }) {
88
+ return fingerprint({
89
+ workflowSchemaVersion: schemaVersion,
90
+ runId,
91
+ apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
92
+ name: operation.name,
93
+ path: pathWithCursor(operation.path, ""),
94
+ pagination: {
95
+ collectionKey: operation.pagination.collectionKey,
96
+ coverageQueue: operation.pagination.coverageQueue || null,
97
+ coverageMode: operation.pagination.coverageMode || null,
98
+ after: operation.pagination.after ?? null,
99
+ filters: operation.pagination.filters || {},
100
+ },
101
+ });
102
+ }
103
+
104
+ function candidateSpoolPath(checkpointPath) {
105
+ return `${checkpointPath}.candidates-${randomUUID()}.ndjson`;
106
+ }
107
+
108
+ function candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath }) {
109
+ const resolvedCheckpoint = path.resolve(checkpointPath);
110
+ const resolvedSpool = path.resolve(String(spoolPath || ""));
111
+ const expectedPrefix = `${path.basename(resolvedCheckpoint)}.candidates-`;
112
+ const spoolName = path.basename(resolvedSpool);
113
+ const spoolIdentifier = spoolName.slice(
114
+ expectedPrefix.length,
115
+ -".ndjson".length,
116
+ );
117
+ return (
118
+ path.dirname(resolvedSpool) === path.dirname(resolvedCheckpoint) &&
119
+ spoolName.startsWith(expectedPrefix) &&
120
+ spoolName.endsWith(".ndjson") &&
121
+ /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
122
+ spoolIdentifier,
123
+ )
124
+ );
125
+ }
126
+
127
+ function createCandidateSpool(checkpointPath) {
128
+ const spoolPath = candidateSpoolPath(checkpointPath);
129
+ mkdirSync(path.dirname(spoolPath), { recursive: true });
130
+ const descriptor = openSync(spoolPath, "wx", 0o600);
131
+ closeSync(descriptor);
132
+ chmodSync(spoolPath, 0o600);
64
133
  return {
134
+ spoolPath,
135
+ spoolHash: createHash("sha256"),
136
+ spoolBytes: 0,
137
+ spoolSha256: createHash("sha256").digest("hex"),
138
+ };
139
+ }
140
+
141
+ function loadSupersededCandidateSpool(checkpointPath) {
142
+ let saved;
143
+ try {
144
+ saved = readAdminJsonFile(checkpointPath, "the previous pagination checkpoint", {
145
+ maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
146
+ });
147
+ } catch {
148
+ return null;
149
+ }
150
+ const spoolPath = path.resolve(String(saved?.spoolPath || ""));
151
+ if (
152
+ saved?.kind !== "admin-pagination" ||
153
+ Number(saved?.schemaVersion) !== ADMIN_PAGINATION_SCHEMA_VERSION ||
154
+ !candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath })
155
+ ) {
156
+ return null;
157
+ }
158
+ try {
159
+ const fileState = lstatSync(spoolPath);
160
+ return fileState.isFile() && !fileState.isSymbolicLink() ? spoolPath : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function appendCandidateItems({ spoolPath, spoolHash, items }) {
167
+ const lines = items.map((item) => {
168
+ const serialized = JSON.stringify(item);
169
+ if (serialized === undefined) {
170
+ throw validationError(
171
+ "The API returned a pagination candidate that cannot be serialized.",
172
+ );
173
+ }
174
+ return serialized;
175
+ });
176
+ if (lines.length === 0) {
177
+ return { addedBytes: 0, spoolSha256: spoolHash.copy().digest("hex") };
178
+ }
179
+ const payload = Buffer.from(`${lines.join("\n")}\n`, "utf8");
180
+ const descriptor = openSync(spoolPath, "a", 0o600);
181
+ try {
182
+ writeFileSync(descriptor, payload);
183
+ fsyncSync(descriptor);
184
+ } finally {
185
+ closeSync(descriptor);
186
+ }
187
+ spoolHash.update(payload);
188
+ return {
189
+ addedBytes: payload.byteLength,
190
+ spoolSha256: spoolHash.copy().digest("hex"),
191
+ };
192
+ }
193
+
194
+ function normalizeCheckpointCount(value, label) {
195
+ const normalized = Number(value);
196
+ if (!Number.isSafeInteger(normalized) || normalized < 0) {
197
+ throw validationError(`The pagination checkpoint has an invalid ${label}.`);
198
+ }
199
+ return normalized;
200
+ }
201
+
202
+ function normalizeFilterCount(value, label) {
203
+ const normalized = Number(value);
204
+ if (!Number.isSafeInteger(normalized) || normalized < 0) {
205
+ throw validationError(`Pagination has an invalid ${label}.`);
206
+ }
207
+ return normalized;
208
+ }
209
+
210
+ function normalizeClientFilterSummary(value) {
211
+ if (value === null || value === undefined) return null;
212
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
213
+ throw validationError("Pagination has invalid client-filter metadata.");
214
+ }
215
+ if (
216
+ !Array.isArray(value.contentTypes) ||
217
+ value.contentTypes.some(
218
+ (contentType) =>
219
+ typeof contentType !== "string" || !contentType.trim(),
220
+ )
221
+ ) {
222
+ throw validationError(
223
+ "Pagination has invalid client-filter content types.",
224
+ );
225
+ }
226
+ return {
227
+ contentTypes: value.contentTypes.map((contentType) => contentType.trim()),
228
+ excludedItems: normalizeFilterCount(
229
+ value.excludedItems,
230
+ "client-filter excludedItems count",
231
+ ),
232
+ };
233
+ }
234
+
235
+ function accumulateClientFilterSummary(previous, pageValue) {
236
+ const page = normalizeClientFilterSummary(pageValue);
237
+ if (!page) return previous;
238
+ if (!previous) return page;
239
+ if (
240
+ JSON.stringify(previous.contentTypes) !== JSON.stringify(page.contentTypes)
241
+ ) {
242
+ throw validationError(
243
+ "The client-side content filter changed while paging one canonical snapshot.",
244
+ );
245
+ }
246
+ return {
247
+ ...previous,
248
+ excludedItems: normalizeFilterCount(
249
+ previous.excludedItems + page.excludedItems,
250
+ "client-filter aggregate excludedItems count",
251
+ ),
252
+ };
253
+ }
254
+
255
+ function normalizeOperatorViewFilterSummary(value) {
256
+ if (value === null || value === undefined) return null;
257
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
258
+ throw validationError("Pagination has invalid operator-view metadata.");
259
+ }
260
+ const mode = String(value.mode || "").trim();
261
+ if (!mode) {
262
+ throw validationError("Pagination has an invalid operator-view mode.");
263
+ }
264
+ return {
265
+ mode,
266
+ excludedItems: normalizeFilterCount(
267
+ value.excludedItems,
268
+ "operator-view excludedItems count",
269
+ ),
270
+ unknownStateItems: normalizeFilterCount(
271
+ value.unknownStateItems,
272
+ "operator-view unknownStateItems count",
273
+ ),
274
+ };
275
+ }
276
+
277
+ function accumulateOperatorViewFilterSummary(previous, pageValue) {
278
+ const page = normalizeOperatorViewFilterSummary(pageValue);
279
+ if (!page) return previous;
280
+ if (!previous) return page;
281
+ if (previous.mode !== page.mode) {
282
+ throw validationError(
283
+ "The operator-view filter changed while paging one canonical snapshot.",
284
+ );
285
+ }
286
+ return {
287
+ ...previous,
288
+ excludedItems: normalizeFilterCount(
289
+ previous.excludedItems + page.excludedItems,
290
+ "operator-view aggregate excludedItems count",
291
+ ),
292
+ unknownStateItems: normalizeFilterCount(
293
+ previous.unknownStateItems + page.unknownStateItems,
294
+ "operator-view aggregate unknownStateItems count",
295
+ ),
296
+ };
297
+ }
298
+
299
+ function verifyCandidateSpool({
300
+ checkpointPath,
301
+ spoolPath,
302
+ spoolBytes,
303
+ spoolSha256,
304
+ candidateCount,
305
+ discardUnconfirmedTail,
306
+ }) {
307
+ if (!candidateSpoolBelongsToCheckpoint({ checkpointPath, spoolPath })) {
308
+ throw validationError(
309
+ "The pagination checkpoint references an invalid candidate spool.",
310
+ );
311
+ }
312
+ const confirmedBytes = normalizeCheckpointCount(spoolBytes, "spoolBytes");
313
+ const confirmedCount = normalizeCheckpointCount(
314
+ candidateCount,
315
+ "candidateCount",
316
+ );
317
+ const expectedDigest = String(spoolSha256 || "").toLowerCase();
318
+ if (!/^[a-f0-9]{64}$/.test(expectedDigest)) {
319
+ throw validationError(
320
+ "The pagination checkpoint has an invalid candidate-spool digest.",
321
+ );
322
+ }
323
+ let fileState;
324
+ try {
325
+ fileState = lstatSync(spoolPath);
326
+ } catch {
327
+ throw validationError(`Could not read pagination candidates at ${spoolPath}.`);
328
+ }
329
+ if (!fileState.isFile() || fileState.isSymbolicLink()) {
330
+ throw validationError(
331
+ "The pagination candidate spool must be a regular private file.",
332
+ );
333
+ }
334
+ if (fileState.size < confirmedBytes) {
335
+ throw validationError(
336
+ "The pagination candidate spool is shorter than its confirmed checkpoint.",
337
+ );
338
+ }
339
+ if (fileState.size > confirmedBytes) {
340
+ if (!discardUnconfirmedTail) {
341
+ throw validationError(
342
+ "The pagination candidate spool changed after its checkpoint was confirmed.",
343
+ );
344
+ }
345
+ truncateSync(spoolPath, confirmedBytes);
346
+ }
347
+ chmodSync(spoolPath, 0o600);
348
+ const spoolHash = createHash("sha256");
349
+ const descriptor = openSync(spoolPath, "r");
350
+ const buffer = Buffer.allocUnsafe(SPOOL_READ_BUFFER_BYTES);
351
+ let remaining = confirmedBytes;
352
+ let lineCount = 0;
353
+ try {
354
+ while (remaining > 0) {
355
+ const bytesRead = readSync(
356
+ descriptor,
357
+ buffer,
358
+ 0,
359
+ Math.min(buffer.length, remaining),
360
+ null,
361
+ );
362
+ if (bytesRead <= 0) {
363
+ throw validationError(
364
+ "The pagination candidate spool ended before its confirmed boundary.",
365
+ );
366
+ }
367
+ const chunk = buffer.subarray(0, bytesRead);
368
+ spoolHash.update(chunk);
369
+ for (let index = 0; index < bytesRead; index += 1) {
370
+ if (chunk[index] === 10) lineCount += 1;
371
+ }
372
+ remaining -= bytesRead;
373
+ }
374
+ } finally {
375
+ closeSync(descriptor);
376
+ }
377
+ if (lineCount !== confirmedCount) {
378
+ throw validationError(
379
+ "The pagination candidate spool count does not match its checkpoint.",
380
+ );
381
+ }
382
+ if (spoolHash.copy().digest("hex") !== expectedDigest) {
383
+ throw validationError(
384
+ "The pagination candidate spool does not match its confirmed checkpoint digest.",
385
+ );
386
+ }
387
+ return spoolHash;
388
+ }
389
+
390
+ function writePaginationCheckpoint(checkpointPath, state) {
391
+ const { resumed: _resumed, ...checkpoint } = state;
392
+ writeAdminJsonFile(
393
+ checkpointPath,
394
+ { ...checkpoint, updatedAt: new Date().toISOString() },
395
+ { privateFile: true, maxBytes: MAX_PAGINATION_CHECKPOINT_BYTES },
396
+ );
397
+ }
398
+
399
+ function paginationStorageFromResult(result) {
400
+ return result?.[PAGINATED_RESULT_STORAGE] || null;
401
+ }
402
+
403
+ export function getPaginatedResultStorage(result) {
404
+ const storage = paginationStorageFromResult(result);
405
+ return storage ? { ...storage } : null;
406
+ }
407
+
408
+ function attachPaginationStorage(result, storage) {
409
+ Object.defineProperty(result, PAGINATED_RESULT_STORAGE, {
410
+ configurable: false,
411
+ enumerable: false,
412
+ writable: false,
413
+ value: Object.freeze({ ...storage }),
414
+ });
415
+ return result;
416
+ }
417
+
418
+ function serializedObjectEntries(value, excludedKeys = new Set()) {
419
+ const entries = [];
420
+ for (const [key, entryValue] of Object.entries(value || {})) {
421
+ if (excludedKeys.has(key)) continue;
422
+ const serialized = JSON.stringify(entryValue);
423
+ if (serialized !== undefined) {
424
+ entries.push(`${JSON.stringify(key)}:${serialized}`);
425
+ }
426
+ }
427
+ return entries;
428
+ }
429
+
430
+ export async function forEachPaginatedResultItem(result, visit) {
431
+ const storage = paginationStorageFromResult(result);
432
+ if (!storage) {
433
+ throw validationError("The result does not contain spooled pagination data.");
434
+ }
435
+ verifyCandidateSpool({ ...storage, discardUnconfirmedTail: false });
436
+ const input = createReadStream(storage.spoolPath, { encoding: "utf8" });
437
+ const observedHash = createHash("sha256");
438
+ let observedBytes = 0;
439
+ input.on("data", (chunk) => {
440
+ observedHash.update(chunk, "utf8");
441
+ observedBytes += Buffer.byteLength(chunk, "utf8");
442
+ });
443
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
444
+ let index = 0;
445
+ try {
446
+ for await (const line of lines) {
447
+ if (!line) continue;
448
+ let item;
449
+ try {
450
+ item = JSON.parse(line);
451
+ } catch {
452
+ throw validationError(
453
+ "The pagination candidate spool contains invalid JSON.",
454
+ );
455
+ }
456
+ await visit(item, index);
457
+ index += 1;
458
+ }
459
+ } finally {
460
+ lines.close();
461
+ input.destroy();
462
+ }
463
+ if (index !== storage.candidateCount) {
464
+ throw validationError(
465
+ "The pagination candidate spool changed while it was being read.",
466
+ );
467
+ }
468
+ if (
469
+ observedBytes !== storage.spoolBytes ||
470
+ observedHash.digest("hex") !== storage.spoolSha256
471
+ ) {
472
+ throw validationError(
473
+ "The pagination candidate spool changed while it was being read.",
474
+ );
475
+ }
476
+ }
477
+
478
+ export async function writePaginatedResultJson({ result, write }) {
479
+ const storage = paginationStorageFromResult(result);
480
+ if (!storage) {
481
+ await write(`${JSON.stringify(result)}\n`);
482
+ return;
483
+ }
484
+ const outerEntries = serializedObjectEntries(result, new Set(["data"]));
485
+ const dataEntries = serializedObjectEntries(
486
+ result.data,
487
+ new Set([storage.collectionKey]),
488
+ );
489
+ let prefix = "{";
490
+ if (outerEntries.length > 0) prefix += outerEntries.join(",");
491
+ if (outerEntries.length > 0) prefix += ",";
492
+ prefix += `${JSON.stringify("data")}:{`;
493
+ if (dataEntries.length > 0) prefix += `${dataEntries.join(",")},`;
494
+ prefix += `${JSON.stringify(storage.collectionKey)}:[`;
495
+ await write(prefix);
496
+ let wroteItem = false;
497
+ await forEachPaginatedResultItem(result, async (item) => {
498
+ await write(`${wroteItem ? "," : ""}${JSON.stringify(item)}`);
499
+ wroteItem = true;
500
+ });
501
+ await write("]}}\n");
502
+ }
503
+
504
+ async function writePaginatedResultFile(filePath, result) {
505
+ const resolved = path.resolve(String(filePath || "").trim());
506
+ if (!String(filePath || "").trim()) {
507
+ throw validationError("An output file path is required.");
508
+ }
509
+ mkdirSync(path.dirname(resolved), { recursive: true });
510
+ const temporary = `${resolved}.tmp-${process.pid}-${randomUUID()}`;
511
+ let descriptor;
512
+ try {
513
+ descriptor = openSync(temporary, "wx", 0o600);
514
+ await writePaginatedResultJson({
515
+ result,
516
+ write: async (chunk) => writeFileSync(descriptor, chunk, "utf8"),
517
+ });
518
+ fsyncSync(descriptor);
519
+ closeSync(descriptor);
520
+ descriptor = undefined;
521
+ renameSync(temporary, resolved);
522
+ chmodSync(resolved, 0o600);
523
+ } catch (error) {
524
+ if (descriptor !== undefined) {
525
+ try {
526
+ closeSync(descriptor);
527
+ } catch {
528
+ // The failed write may already have closed the descriptor.
529
+ }
530
+ }
531
+ try {
532
+ unlinkSync(temporary);
533
+ } catch {
534
+ // Exclusive create or successful rename may leave nothing to clean.
535
+ }
536
+ throw error;
537
+ }
538
+ return resolved;
539
+ }
540
+
541
+ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
542
+ const last = lastPage || { ok: true, status: "success", data: {} };
543
+ const lastData = { ...(last.data || {}) };
544
+ delete lastData[operation.pagination.collectionKey];
545
+ if (state.filterSummariesComplete) {
546
+ if (state.clientFilter) lastData.clientFilter = state.clientFilter;
547
+ else delete lastData.clientFilter;
548
+ if (state.operatorViewFilter) {
549
+ lastData.operatorViewFilter = state.operatorViewFilter;
550
+ } else {
551
+ delete lastData.operatorViewFilter;
552
+ }
553
+ } else {
554
+ // Legacy v2 checkpoints stored filtered candidates but not exact per-page
555
+ // exclusion counters. Do not present a last-page subtotal as a full-scan
556
+ // total after migrating one of those checkpoints.
557
+ delete lastData.clientFilter;
558
+ delete lastData.operatorViewFilter;
559
+ }
560
+ const candidateStorage = {
561
+ format: "ndjson",
562
+ path: state.spoolPath,
563
+ byteLength: state.spoolBytes,
564
+ sha256: state.spoolSha256,
565
+ };
566
+ const result = {
65
567
  ...last,
66
568
  data: {
67
- ...(last.data || {}),
68
- [operation.pagination.collectionKey]: state.items,
569
+ ...lastData,
69
570
  pagination: {
70
571
  ...(last.data?.pagination || {}),
71
572
  nextCursor: state.nextCursor,
72
573
  hasMore: !state.exhausted,
73
574
  exhausted: state.exhausted,
74
575
  after: state.after,
576
+ snapshotMaxId: state.snapshotMaxId,
75
577
  snapshotTimeStamp: state.snapshotTimeStamp,
76
578
  },
77
579
  scan: {
78
580
  pages: state.pages,
79
581
  scannedCount: state.scannedCount,
80
- candidateCount: state.items.length,
582
+ candidateCount: state.candidateCount,
81
583
  checkpointPath,
82
584
  resumed: state.resumed,
585
+ filterSummariesComplete: state.filterSummariesComplete,
586
+ candidateStorage,
83
587
  },
84
588
  },
85
589
  };
590
+ return attachPaginationStorage(result, {
591
+ checkpointPath,
592
+ collectionKey: operation.pagination.collectionKey,
593
+ spoolPath: state.spoolPath,
594
+ spoolBytes: state.spoolBytes,
595
+ spoolSha256: state.spoolSha256,
596
+ candidateCount: state.candidateCount,
597
+ });
86
598
  }
87
599
 
88
600
  export async function runAutomaticPagination({
@@ -101,19 +613,29 @@ export async function runAutomaticPagination({
101
613
  options.adminCheckpoint ||
102
614
  defaultCheckpointPath({ runId, operationName: operation.name }),
103
615
  );
104
- const operationFingerprint = fingerprint({
105
- workflowSchemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
616
+ const requestedOutputPath = String(options.adminOutput || "").trim();
617
+ if (options.adminOutput && !requestedOutputPath) {
618
+ throw validationError("An output file path is required.");
619
+ }
620
+ const outputPath = requestedOutputPath
621
+ ? path.resolve(requestedOutputPath)
622
+ : null;
623
+ if (outputPath === checkpointPath) {
624
+ throw validationError(
625
+ "The pagination checkpoint and result output must use different files.",
626
+ );
627
+ }
628
+ const operationFingerprint = operationFingerprintValue({
629
+ schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
630
+ options,
631
+ operation,
632
+ runId,
633
+ });
634
+ const legacyOperationFingerprint = operationFingerprintValue({
635
+ schemaVersion: LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION,
636
+ options,
637
+ operation,
106
638
  runId,
107
- apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
108
- name: operation.name,
109
- path: pathWithCursor(operation.path, ""),
110
- pagination: {
111
- collectionKey: operation.pagination.collectionKey,
112
- coverageQueue: operation.pagination.coverageQueue || null,
113
- coverageMode: operation.pagination.coverageMode || null,
114
- after: operation.pagination.after ?? null,
115
- filters: operation.pagination.filters || {},
116
- },
117
639
  });
118
640
  let state = {
119
641
  schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
@@ -129,29 +651,188 @@ export async function runAutomaticPagination({
129
651
  snapshotTimeStamp: null,
130
652
  after: operation.pagination.after ?? null,
131
653
  boundariesConfirmed: false,
132
- items: [],
654
+ candidateCount: 0,
655
+ spoolPath: "",
656
+ spoolBytes: 0,
657
+ spoolSha256: "",
658
+ filterSummariesComplete: true,
659
+ clientFilter: null,
660
+ operatorViewFilter: null,
133
661
  resumed: false,
134
662
  updatedAt: new Date().toISOString(),
135
663
  };
664
+ let spoolHash;
136
665
  if (options.adminResume) {
137
666
  const saved = readAdminJsonFile(
138
667
  checkpointPath,
139
668
  "the pagination checkpoint",
140
- { maxBytes: MAX_ADMIN_CHECKPOINT_BYTES },
669
+ { maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES },
670
+ );
671
+ const savedSchemaVersion = Number(saved?.schemaVersion);
672
+ const savedPages = normalizeCheckpointCount(saved?.pages, "pages");
673
+ const savedScannedCount = normalizeCheckpointCount(
674
+ saved?.scannedCount,
675
+ "scannedCount",
141
676
  );
677
+ const baseMatches =
678
+ saved?.kind === "admin-pagination" &&
679
+ Number(saved?.runId) === runId &&
680
+ ((savedPages === 0 && saved?.boundariesConfirmed === false) ||
681
+ (savedPages > 0 && saved?.boundariesConfirmed === true));
682
+ const currentMatches =
683
+ savedSchemaVersion === ADMIN_PAGINATION_SCHEMA_VERSION &&
684
+ saved?.operationFingerprint === operationFingerprint;
685
+ const legacyMatches =
686
+ savedSchemaVersion === LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION &&
687
+ saved?.operationFingerprint === legacyOperationFingerprint &&
688
+ Array.isArray(saved?.items) &&
689
+ savedPages > 0;
690
+ if (!baseMatches || (!currentMatches && !legacyMatches)) {
691
+ throw validationError(
692
+ "The checkpoint does not belong to this run and exact listing request.",
693
+ );
694
+ }
695
+ const savedNextCursor = String(saved?.nextCursor || "");
696
+ const savedExhausted = saved?.exhausted;
142
697
  if (
143
- saved?.schemaVersion !== ADMIN_PAGINATION_SCHEMA_VERSION ||
144
- saved?.kind !== "admin-pagination" ||
145
- saved?.operationFingerprint !== operationFingerprint ||
146
- Number(saved?.runId) !== runId ||
147
- !Array.isArray(saved?.items) ||
148
- saved?.boundariesConfirmed !== true
698
+ typeof savedExhausted !== "boolean" ||
699
+ (!savedExhausted && savedPages > 0 && !savedNextCursor) ||
700
+ (savedExhausted && savedNextCursor)
149
701
  ) {
150
702
  throw validationError(
151
- "The checkpoint does not belong to this run and exact listing request.",
703
+ "The pagination checkpoint has inconsistent exhaustion metadata.",
152
704
  );
153
705
  }
154
- state = { ...state, ...saved, resumed: true };
706
+ if (legacyMatches) {
707
+ const createdSpool = createCandidateSpool(checkpointPath);
708
+ spoolHash = createdSpool.spoolHash;
709
+ let spoolBytes = 0;
710
+ let spoolSha256 = createdSpool.spoolSha256;
711
+ try {
712
+ for (let index = 0; index < saved.items.length; index += 250) {
713
+ const appended = appendCandidateItems({
714
+ spoolPath: createdSpool.spoolPath,
715
+ spoolHash,
716
+ items: saved.items.slice(index, index + 250),
717
+ });
718
+ spoolBytes += appended.addedBytes;
719
+ spoolSha256 = appended.spoolSha256;
720
+ }
721
+ state = {
722
+ ...state,
723
+ nextCursor: savedNextCursor,
724
+ exhausted: savedExhausted,
725
+ pages: savedPages,
726
+ scannedCount: savedScannedCount,
727
+ snapshotMaxId: normalizeBoundary(
728
+ saved?.snapshotMaxId,
729
+ "snapshotMaxId",
730
+ ),
731
+ snapshotTimeStamp: normalizeBoundary(
732
+ saved?.snapshotTimeStamp,
733
+ "snapshotTimeStamp",
734
+ ),
735
+ after: normalizeBoundary(saved?.after, "after"),
736
+ boundariesConfirmed: true,
737
+ candidateCount: saved.items.length,
738
+ spoolPath: createdSpool.spoolPath,
739
+ spoolBytes,
740
+ spoolSha256,
741
+ filterSummariesComplete: false,
742
+ resumed: true,
743
+ };
744
+ writePaginationCheckpoint(checkpointPath, state);
745
+ } catch (error) {
746
+ try {
747
+ unlinkSync(createdSpool.spoolPath);
748
+ } catch {
749
+ // A failed migration may have created no durable candidate spool.
750
+ }
751
+ throw error;
752
+ }
753
+ } else {
754
+ const candidateCount = normalizeCheckpointCount(
755
+ saved?.candidateCount,
756
+ "candidateCount",
757
+ );
758
+ state = {
759
+ ...state,
760
+ nextCursor: savedNextCursor,
761
+ exhausted: savedExhausted,
762
+ pages: savedPages,
763
+ scannedCount: savedScannedCount,
764
+ snapshotMaxId:
765
+ savedPages > 0
766
+ ? normalizeBoundary(saved?.snapshotMaxId, "snapshotMaxId")
767
+ : null,
768
+ snapshotTimeStamp:
769
+ savedPages > 0
770
+ ? normalizeBoundary(
771
+ saved?.snapshotTimeStamp,
772
+ "snapshotTimeStamp",
773
+ )
774
+ : null,
775
+ after:
776
+ savedPages > 0
777
+ ? normalizeBoundary(saved?.after, "after")
778
+ : operation.pagination.after ?? null,
779
+ boundariesConfirmed: saved?.boundariesConfirmed,
780
+ candidateCount,
781
+ spoolPath: path.resolve(String(saved?.spoolPath || "")),
782
+ spoolBytes: normalizeCheckpointCount(saved?.spoolBytes, "spoolBytes"),
783
+ spoolSha256: String(saved?.spoolSha256 || "").toLowerCase(),
784
+ filterSummariesComplete:
785
+ savedPages === 0 || saved?.filterSummariesComplete === true,
786
+ clientFilter:
787
+ savedPages > 0 && saved?.filterSummariesComplete === true
788
+ ? normalizeClientFilterSummary(saved?.clientFilter)
789
+ : null,
790
+ operatorViewFilter:
791
+ savedPages > 0 && saved?.filterSummariesComplete === true
792
+ ? normalizeOperatorViewFilterSummary(saved?.operatorViewFilter)
793
+ : null,
794
+ resumed: true,
795
+ };
796
+ spoolHash = verifyCandidateSpool({
797
+ checkpointPath,
798
+ spoolPath: state.spoolPath,
799
+ spoolBytes: state.spoolBytes,
800
+ spoolSha256: state.spoolSha256,
801
+ candidateCount: state.candidateCount,
802
+ discardUnconfirmedTail: true,
803
+ });
804
+ }
805
+ } else {
806
+ const supersededSpoolPath = loadSupersededCandidateSpool(checkpointPath);
807
+ const createdSpool = createCandidateSpool(checkpointPath);
808
+ spoolHash = createdSpool.spoolHash;
809
+ state.spoolPath = createdSpool.spoolPath;
810
+ state.spoolBytes = createdSpool.spoolBytes;
811
+ state.spoolSha256 = createdSpool.spoolSha256;
812
+ try {
813
+ writePaginationCheckpoint(checkpointPath, state);
814
+ } catch (error) {
815
+ try {
816
+ unlinkSync(state.spoolPath);
817
+ } catch {
818
+ // The failed initialization may leave no spool to clean.
819
+ }
820
+ throw error;
821
+ }
822
+ if (supersededSpoolPath && supersededSpoolPath !== state.spoolPath) {
823
+ try {
824
+ unlinkSync(supersededSpoolPath);
825
+ } catch (error) {
826
+ reportProgress(
827
+ `Lumine admin ${operation.name}: could not remove superseded candidate spool ${supersededSpoolPath} (${error?.message || error}).`,
828
+ );
829
+ }
830
+ }
831
+ }
832
+ if (outputPath === path.resolve(state.spoolPath)) {
833
+ throw validationError(
834
+ "The pagination candidate spool and result output must use different files.",
835
+ );
155
836
  }
156
837
  const progressEnabled = options.json === true;
157
838
  if (progressEnabled) {
@@ -159,17 +840,17 @@ export async function runAutomaticPagination({
159
840
  `Lumine admin ${operation.name}: ${state.resumed ? "resuming" : "starting"} canonical scan; checkpoint ${checkpointPath}.`,
160
841
  );
161
842
  }
162
- const pages = [];
843
+ let lastPage = null;
163
844
  while (!state.exhausted) {
164
845
  if (state.pages >= MAX_AUTOMATIC_PAGES) {
165
846
  throw validationError(
166
- `Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Resume after narrowing the request.`,
847
+ `Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Start a narrower request with a new checkpoint.`,
167
848
  );
168
849
  }
169
850
  const requestPath = pathWithCursor(operation.path, state.nextCursor);
170
851
  const raw = await fetchPage(requestPath);
171
852
  const page = transformPage(raw);
172
- pages.push(page);
853
+ lastPage = page;
173
854
  const data = page?.data || {};
174
855
  const pagination = data.pagination;
175
856
  if (!pagination || typeof pagination !== "object" || Array.isArray(pagination)) {
@@ -224,31 +905,52 @@ export async function runAutomaticPagination({
224
905
  state.after = requestedAfter ?? pageAfter;
225
906
  state.boundariesConfirmed = true;
226
907
  }
227
- state.items.push(...items);
228
- state.pages += 1;
229
- state.scannedCount += normalizeScannedCount(
230
- pagination.scannedCount,
231
- items.length,
232
- );
233
- state.nextCursor = nextCursor;
234
- state.exhausted = pagination.exhausted;
235
- state.updatedAt = new Date().toISOString();
236
- writeAdminJsonFile(checkpointPath, state, {
237
- privateFile: true,
238
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
908
+ const appended = appendCandidateItems({
909
+ spoolPath: state.spoolPath,
910
+ spoolHash,
911
+ items,
239
912
  });
913
+ const clientFilter = state.filterSummariesComplete
914
+ ? accumulateClientFilterSummary(state.clientFilter, data.clientFilter)
915
+ : null;
916
+ const operatorViewFilter = state.filterSummariesComplete
917
+ ? accumulateOperatorViewFilterSummary(
918
+ state.operatorViewFilter,
919
+ data.operatorViewFilter,
920
+ )
921
+ : null;
922
+ const nextState = {
923
+ ...state,
924
+ pages: state.pages + 1,
925
+ scannedCount:
926
+ state.scannedCount +
927
+ normalizeScannedCount(
928
+ pagination.scannedCount,
929
+ items.length,
930
+ ),
931
+ candidateCount: state.candidateCount + items.length,
932
+ spoolBytes: state.spoolBytes + appended.addedBytes,
933
+ spoolSha256: appended.spoolSha256,
934
+ clientFilter,
935
+ operatorViewFilter,
936
+ nextCursor,
937
+ exhausted: pagination.exhausted,
938
+ updatedAt: new Date().toISOString(),
939
+ };
940
+ writePaginationCheckpoint(checkpointPath, nextState);
941
+ state = nextState;
240
942
  if (
241
943
  progressEnabled &&
242
- (pages.length === 1 || state.pages % 10 === 0 || state.exhausted)
944
+ (state.pages === 1 || state.pages % 10 === 0 || state.exhausted)
243
945
  ) {
244
946
  reportProgress(
245
- `Lumine admin ${operation.name}: ${state.pages} page(s), ${state.scannedCount} row(s) scanned, ${state.items.length} candidate(s)${state.exhausted ? "; canonical snapshot exhausted." : "; continuing."}`,
947
+ `Lumine admin ${operation.name}: ${state.pages} page(s), ${state.scannedCount} row(s) scanned, ${state.candidateCount} candidate(s)${state.exhausted ? "; canonical snapshot exhausted." : "; continuing."}`,
246
948
  );
247
949
  }
248
950
  }
249
951
  const result = aggregatePageResult({
250
952
  operation,
251
- pages,
953
+ lastPage,
252
954
  checkpointPath,
253
955
  state,
254
956
  });
@@ -259,18 +961,16 @@ export async function runAutomaticPagination({
259
961
  after: state.after,
260
962
  pages: state.pages,
261
963
  scannedCount: state.scannedCount,
262
- candidateCount: state.items.length,
964
+ candidateCount: state.candidateCount,
263
965
  snapshotMaxId: state.snapshotMaxId,
264
966
  snapshotTimeStamp: state.snapshotTimeStamp,
265
967
  exhausted: state.exhausted,
266
968
  filters: operation.pagination.filters || {},
267
969
  });
268
970
  }
269
- if (options.adminOutput) {
270
- result.data.scan.outputPath = writeAdminJsonFile(options.adminOutput, result, {
271
- privateFile: true,
272
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
273
- });
971
+ if (outputPath) {
972
+ result.data.scan.outputPath = outputPath;
973
+ await writePaginatedResultFile(outputPath, result);
274
974
  }
275
975
  return result;
276
976
  }
@@ -365,7 +1065,7 @@ export async function runBatchSkips({
365
1065
  };
366
1066
  if (options.adminResume) {
367
1067
  const saved = readAdminJsonFile(checkpointPath, "the batch checkpoint", {
368
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
1068
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
369
1069
  });
370
1070
  if (
371
1071
  saved?.schemaVersion !== ADMIN_BATCH_SCHEMA_VERSION ||
@@ -381,7 +1081,7 @@ export async function runBatchSkips({
381
1081
  } else {
382
1082
  writeAdminJsonFile(checkpointPath, state, {
383
1083
  privateFile: true,
384
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
1084
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
385
1085
  });
386
1086
  }
387
1087
  for (const target of targets) {
@@ -405,7 +1105,7 @@ export async function runBatchSkips({
405
1105
  state.updatedAt = new Date().toISOString();
406
1106
  writeAdminJsonFile(checkpointPath, state, {
407
1107
  privateFile: true,
408
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
1108
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
409
1109
  });
410
1110
  }
411
1111
  const result = {
@@ -427,7 +1127,7 @@ export async function runBatchSkips({
427
1127
  if (options.adminOutput) {
428
1128
  result.data.batch.outputPath = writeAdminJsonFile(options.adminOutput, result, {
429
1129
  privateFile: true,
430
- maxBytes: MAX_ADMIN_CHECKPOINT_BYTES,
1130
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
431
1131
  });
432
1132
  }
433
1133
  return result;