@stage5/lumine 0.2.66 → 0.2.68

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.
@@ -130,7 +130,7 @@ function pathEntryExists(filePath) {
130
130
  }
131
131
  }
132
132
 
133
- function acquireCheckpointLock(checkpointPath, operationFingerprint) {
133
+ export function acquireCheckpointLock(checkpointPath, operationFingerprint) {
134
134
  const lockPath = `${checkpointPath}.lock`;
135
135
  const reclaimPath = `${lockPath}.reclaim`;
136
136
  mkdirSync(path.dirname(lockPath), { recursive: true });
@@ -253,7 +253,7 @@ function removeDeadCheckpointLock(lockPath) {
253
253
  }
254
254
  }
255
255
 
256
- function releaseCheckpointLock(lock) {
256
+ export function releaseCheckpointLock(lock) {
257
257
  try {
258
258
  const fileState = lstatSync(lock.lockPath);
259
259
  if (!fileState.isFile() || fileState.isSymbolicLink()) return;
@@ -339,9 +339,13 @@ function createCandidateSpool(checkpointPath) {
339
339
  function loadSupersededCandidateSpool(checkpointPath) {
340
340
  let saved;
341
341
  try {
342
- saved = readAdminJsonFile(checkpointPath, "the previous pagination checkpoint", {
343
- maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
344
- });
342
+ saved = readAdminJsonFile(
343
+ checkpointPath,
344
+ "the previous pagination checkpoint",
345
+ {
346
+ maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
347
+ },
348
+ );
345
349
  } catch {
346
350
  return null;
347
351
  }
@@ -413,8 +417,7 @@ function normalizeClientFilterSummary(value) {
413
417
  if (
414
418
  !Array.isArray(value.contentTypes) ||
415
419
  value.contentTypes.some(
416
- (contentType) =>
417
- typeof contentType !== "string" || !contentType.trim(),
420
+ (contentType) => typeof contentType !== "string" || !contentType.trim(),
418
421
  )
419
422
  ) {
420
423
  throw validationError(
@@ -522,7 +525,9 @@ function verifyCandidateSpool({
522
525
  try {
523
526
  fileState = lstatSync(spoolPath);
524
527
  } catch {
525
- throw validationError(`Could not read pagination candidates at ${spoolPath}.`);
528
+ throw validationError(
529
+ `Could not read pagination candidates at ${spoolPath}.`,
530
+ );
526
531
  }
527
532
  if (!fileState.isFile() || fileState.isSymbolicLink()) {
528
533
  throw validationError(
@@ -628,7 +633,9 @@ function serializedObjectEntries(value, excludedKeys = new Set()) {
628
633
  export async function forEachPaginatedResultItem(result, visit) {
629
634
  const storage = paginationStorageFromResult(result);
630
635
  if (!storage) {
631
- throw validationError("The result does not contain spooled pagination data.");
636
+ throw validationError(
637
+ "The result does not contain spooled pagination data.",
638
+ );
632
639
  }
633
640
  verifyCandidateSpool({ ...storage, discardUnconfirmedTail: false });
634
641
  const input = createReadStream(storage.spoolPath, { encoding: "utf8" });
@@ -805,7 +812,9 @@ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
805
812
  export async function runAutomaticPagination(args) {
806
813
  const { options, operation, runId } = args;
807
814
  if (!operation.pagination) {
808
- throw validationError("--all is supported only by paginated admin listings.");
815
+ throw validationError(
816
+ "--all is supported only by paginated admin listings.",
817
+ );
809
818
  }
810
819
  const operationFingerprint = operationFingerprintValue({
811
820
  schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
@@ -820,13 +829,51 @@ export async function runAutomaticPagination(args) {
820
829
  operationFingerprint,
821
830
  });
822
831
  const lock = acquireCheckpointLock(checkpointPath, operationFingerprint);
832
+ const signalSource = args.signalSource || process;
833
+ const controller = new AbortController();
834
+ let interruptedSignal = "";
835
+ const interrupt = (signalName) => {
836
+ interruptedSignal ||= signalName;
837
+ controller.abort(
838
+ new Error(`Lumine admin scan interrupted by ${signalName}.`),
839
+ );
840
+ };
841
+ const onSigint = () => interrupt("SIGINT");
842
+ const onSigterm = () => interrupt("SIGTERM");
843
+ signalSource.once("SIGINT", onSigint);
844
+ signalSource.once("SIGTERM", onSigterm);
823
845
  try {
824
846
  return await runAutomaticPaginationWithLock({
825
847
  ...args,
826
848
  checkpointPath,
827
849
  operationFingerprint,
850
+ signal: controller.signal,
828
851
  });
852
+ } catch (error) {
853
+ if (interruptedSignal) {
854
+ const interrupted = validationError(
855
+ `Canonical scan cancelled by ${interruptedSignal}. Confirmed progress remains at ${checkpointPath}; rerun the same command with --resume to continue it.`,
856
+ );
857
+ interrupted.code = "LUMINE_ADMIN_SCAN_CANCELLED";
858
+ interrupted.data = {
859
+ ok: false,
860
+ status: "validation_error",
861
+ error: {
862
+ code: interrupted.code,
863
+ message: interrupted.message,
864
+ details: {
865
+ checkpointPath,
866
+ signal: interruptedSignal,
867
+ resumable: true,
868
+ },
869
+ },
870
+ };
871
+ throw interrupted;
872
+ }
873
+ throw error;
829
874
  } finally {
875
+ signalSource.removeListener("SIGINT", onSigint);
876
+ signalSource.removeListener("SIGTERM", onSigterm);
830
877
  releaseCheckpointLock(lock);
831
878
  }
832
879
  }
@@ -841,6 +888,7 @@ async function runAutomaticPaginationWithLock({
841
888
  transformPage,
842
889
  recordCoverage,
843
890
  reportProgress = (message) => process.stderr.write(`${message}\n`),
891
+ signal,
844
892
  }) {
845
893
  const requestedOutputPath = String(options.adminOutput || "").trim();
846
894
  if (options.adminOutput && !requestedOutputPath) {
@@ -990,15 +1038,12 @@ async function runAutomaticPaginationWithLock({
990
1038
  : null,
991
1039
  snapshotTimeStamp:
992
1040
  savedPages > 0
993
- ? normalizeBoundary(
994
- saved?.snapshotTimeStamp,
995
- "snapshotTimeStamp",
996
- )
1041
+ ? normalizeBoundary(saved?.snapshotTimeStamp, "snapshotTimeStamp")
997
1042
  : null,
998
1043
  after:
999
1044
  savedPages > 0
1000
1045
  ? normalizeBoundary(saved?.after, "after")
1001
- : operation.pagination.after ?? null,
1046
+ : (operation.pagination.after ?? null),
1002
1047
  boundariesConfirmed: saved?.boundariesConfirmed,
1003
1048
  candidateCount,
1004
1049
  spoolPath: path.resolve(String(saved?.spoolPath || "")),
@@ -1065,19 +1110,27 @@ async function runAutomaticPaginationWithLock({
1065
1110
  }
1066
1111
  let lastPage = null;
1067
1112
  while (!state.exhausted) {
1113
+ signal?.throwIfAborted();
1068
1114
  if (state.pages >= MAX_AUTOMATIC_PAGES) {
1069
1115
  throw validationError(
1070
1116
  `Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Start a narrower request with a new checkpoint.`,
1071
1117
  );
1072
1118
  }
1073
1119
  const requestPath = pathWithCursor(operation.path, state.nextCursor);
1074
- const raw = await fetchPage(requestPath);
1120
+ const raw = await fetchPage(requestPath, signal);
1121
+ signal?.throwIfAborted();
1075
1122
  const page = transformPage(raw);
1076
1123
  lastPage = page;
1077
1124
  const data = page?.data || {};
1078
1125
  const pagination = data.pagination;
1079
- if (!pagination || typeof pagination !== "object" || Array.isArray(pagination)) {
1080
- throw validationError("The API response is missing canonical pagination metadata.");
1126
+ if (
1127
+ !pagination ||
1128
+ typeof pagination !== "object" ||
1129
+ Array.isArray(pagination)
1130
+ ) {
1131
+ throw validationError(
1132
+ "The API response is missing canonical pagination metadata.",
1133
+ );
1081
1134
  }
1082
1135
  const items = Array.isArray(data[operation.pagination.collectionKey])
1083
1136
  ? data[operation.pagination.collectionKey]
@@ -1089,7 +1142,9 @@ async function runAutomaticPaginationWithLock({
1089
1142
  }
1090
1143
  const nextCursor = String(pagination.nextCursor || "");
1091
1144
  if (nextCursor && nextCursor === state.nextCursor) {
1092
- throw validationError("The API returned the same pagination cursor twice.");
1145
+ throw validationError(
1146
+ "The API returned the same pagination cursor twice.",
1147
+ );
1093
1148
  }
1094
1149
  if (!pagination.exhausted && !nextCursor) {
1095
1150
  throw validationError(
@@ -1111,7 +1166,11 @@ async function runAutomaticPaginationWithLock({
1111
1166
  );
1112
1167
  const pageAfter = normalizeBoundary(pagination.after, "after");
1113
1168
  if (state.boundariesConfirmed) {
1114
- assertStableBoundary("snapshotMaxId", state.snapshotMaxId, pageSnapshotMaxId);
1169
+ assertStableBoundary(
1170
+ "snapshotMaxId",
1171
+ state.snapshotMaxId,
1172
+ pageSnapshotMaxId,
1173
+ );
1115
1174
  assertStableBoundary(
1116
1175
  "snapshotTimeStamp",
1117
1176
  state.snapshotTimeStamp,
@@ -1119,7 +1178,10 @@ async function runAutomaticPaginationWithLock({
1119
1178
  );
1120
1179
  assertStableBoundary("after", state.after, pageAfter);
1121
1180
  } else {
1122
- const requestedAfter = normalizeBoundary(operation.pagination.after, "after");
1181
+ const requestedAfter = normalizeBoundary(
1182
+ operation.pagination.after,
1183
+ "after",
1184
+ );
1123
1185
  if (requestedAfter !== null) {
1124
1186
  assertStableBoundary("after", requestedAfter, pageAfter);
1125
1187
  }
@@ -1147,10 +1209,7 @@ async function runAutomaticPaginationWithLock({
1147
1209
  pages: state.pages + 1,
1148
1210
  scannedCount:
1149
1211
  state.scannedCount +
1150
- normalizeScannedCount(
1151
- pagination.scannedCount,
1152
- items.length,
1153
- ),
1212
+ normalizeScannedCount(pagination.scannedCount, items.length),
1154
1213
  candidateCount: state.candidateCount + items.length,
1155
1214
  spoolBytes: state.spoolBytes + appended.addedBytes,
1156
1215
  spoolSha256: appended.spoolSha256,
@@ -1177,19 +1236,24 @@ async function runAutomaticPaginationWithLock({
1177
1236
  checkpointPath,
1178
1237
  state,
1179
1238
  });
1239
+ signal?.throwIfAborted();
1180
1240
  if (recordCoverage && operation.pagination.coverageQueue) {
1181
- await recordCoverage({
1182
- queue: operation.pagination.coverageQueue,
1183
- mode: operation.pagination.coverageMode || "all",
1184
- after: state.after,
1185
- pages: state.pages,
1186
- scannedCount: state.scannedCount,
1187
- candidateCount: state.candidateCount,
1188
- snapshotMaxId: state.snapshotMaxId,
1189
- snapshotTimeStamp: state.snapshotTimeStamp,
1190
- exhausted: state.exhausted,
1191
- filters: operation.pagination.filters || {},
1192
- });
1241
+ await recordCoverage(
1242
+ {
1243
+ queue: operation.pagination.coverageQueue,
1244
+ mode: operation.pagination.coverageMode || "all",
1245
+ after: state.after,
1246
+ pages: state.pages,
1247
+ scannedCount: state.scannedCount,
1248
+ candidateCount: state.candidateCount,
1249
+ snapshotMaxId: state.snapshotMaxId,
1250
+ snapshotTimeStamp: state.snapshotTimeStamp,
1251
+ exhausted: state.exhausted,
1252
+ filters: operation.pagination.filters || {},
1253
+ },
1254
+ signal,
1255
+ );
1256
+ signal?.throwIfAborted();
1193
1257
  }
1194
1258
  if (outputPath) {
1195
1259
  result.data.scan.outputPath = outputPath;
@@ -1226,12 +1290,16 @@ export function readBatchSkipTargets({ filePath, parseTarget, defaultReason }) {
1226
1290
  throw validationError("The batch target file contains no targets.");
1227
1291
  }
1228
1292
  if (values.length > MAX_BATCH_TARGETS) {
1229
- throw validationError(`A batch can contain at most ${MAX_BATCH_TARGETS} targets.`);
1293
+ throw validationError(
1294
+ `A batch can contain at most ${MAX_BATCH_TARGETS} targets.`,
1295
+ );
1230
1296
  }
1231
1297
  const deduped = new Map();
1232
1298
  for (const value of values) {
1233
1299
  const rawTarget =
1234
- typeof value === "string" ? value : value?.target || value?.url || value?.id;
1300
+ typeof value === "string"
1301
+ ? value
1302
+ : value?.target || value?.url || value?.id;
1235
1303
  const parsed = parseTarget({
1236
1304
  target: rawTarget,
1237
1305
  explicitType: typeof value === "object" ? value?.type || "" : "",
@@ -1298,7 +1366,9 @@ export async function runBatchSkips({
1298
1366
  !saved.completed ||
1299
1367
  typeof saved.completed !== "object"
1300
1368
  ) {
1301
- throw validationError("The checkpoint does not match this exact skip batch.");
1369
+ throw validationError(
1370
+ "The checkpoint does not match this exact skip batch.",
1371
+ );
1302
1372
  }
1303
1373
  state = saved;
1304
1374
  } else {
@@ -1339,8 +1409,9 @@ export async function runBatchSkips({
1339
1409
  batch: {
1340
1410
  targetCount: targets.length,
1341
1411
  completedCount: Object.keys(state.completed).length,
1342
- changedCount: Object.values(state.completed).filter((entry) => entry.changed)
1343
- .length,
1412
+ changedCount: Object.values(state.completed).filter(
1413
+ (entry) => entry.changed,
1414
+ ).length,
1344
1415
  checkpointPath,
1345
1416
  resumed: options.adminResume === true,
1346
1417
  targets: state.completed,
@@ -1348,10 +1419,14 @@ export async function runBatchSkips({
1348
1419
  },
1349
1420
  };
1350
1421
  if (options.adminOutput) {
1351
- result.data.batch.outputPath = writeAdminJsonFile(options.adminOutput, result, {
1352
- privateFile: true,
1353
- maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
1354
- });
1422
+ result.data.batch.outputPath = writeAdminJsonFile(
1423
+ options.adminOutput,
1424
+ result,
1425
+ {
1426
+ privateFile: true,
1427
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
1428
+ },
1429
+ );
1355
1430
  }
1356
1431
  return result;
1357
1432
  }