@stage5/lumine 0.2.65 → 0.2.67

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.
@@ -3,7 +3,9 @@ import {
3
3
  chmodSync,
4
4
  closeSync,
5
5
  createReadStream,
6
+ fchmodSync,
6
7
  fsyncSync,
8
+ linkSync,
7
9
  lstatSync,
8
10
  mkdirSync,
9
11
  openSync,
@@ -74,9 +76,198 @@ function pathWithCursor(requestPath, cursor) {
74
76
  return `${url.pathname}${url.search}`;
75
77
  }
76
78
 
77
- function defaultCheckpointPath({ runId, operationName }) {
79
+ function defaultCheckpointPath({
80
+ runId,
81
+ operationName,
82
+ operationFingerprint = "",
83
+ }) {
78
84
  const safeName = String(operationName).replace(/[^a-z0-9_.-]+/gi, "-");
79
- return path.join(os.tmpdir(), `lumine-admin-run-${runId}-${safeName}.json`);
85
+ const fingerprintSuffix = operationFingerprint
86
+ ? `-${operationFingerprint}`
87
+ : "";
88
+ return path.join(
89
+ os.tmpdir(),
90
+ `lumine-admin-run-${runId}-${safeName}${fingerprintSuffix}.json`,
91
+ );
92
+ }
93
+
94
+ function resolveAutomaticCheckpointPath({
95
+ options,
96
+ operation,
97
+ runId,
98
+ operationFingerprint,
99
+ }) {
100
+ if (String(options.adminCheckpoint || "").trim()) {
101
+ return path.resolve(options.adminCheckpoint);
102
+ }
103
+ const keyedPath = defaultCheckpointPath({
104
+ runId,
105
+ operationName: operation.name,
106
+ operationFingerprint,
107
+ });
108
+ if (
109
+ options.adminResume &&
110
+ !pathEntryExists(keyedPath) &&
111
+ !pathEntryExists(`${keyedPath}.lock`) &&
112
+ !pathEntryExists(`${keyedPath}.lock.reclaim`)
113
+ ) {
114
+ const legacyPath = defaultCheckpointPath({
115
+ runId,
116
+ operationName: operation.name,
117
+ });
118
+ if (pathEntryExists(legacyPath)) return legacyPath;
119
+ }
120
+ return keyedPath;
121
+ }
122
+
123
+ function pathEntryExists(filePath) {
124
+ try {
125
+ lstatSync(filePath);
126
+ return true;
127
+ } catch (error) {
128
+ if (error?.code === "ENOENT") return false;
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ function acquireCheckpointLock(checkpointPath, operationFingerprint) {
134
+ const lockPath = `${checkpointPath}.lock`;
135
+ const reclaimPath = `${lockPath}.reclaim`;
136
+ mkdirSync(path.dirname(lockPath), { recursive: true });
137
+ for (let attempt = 0; attempt < 2; attempt += 1) {
138
+ if (pathEntryExists(reclaimPath)) {
139
+ throw validationError(
140
+ `Another Lumine admin scan is recovering checkpoint ${checkpointPath}. Wait for it to finish or choose a different --checkpoint.`,
141
+ );
142
+ }
143
+ const token = randomUUID();
144
+ let descriptor;
145
+ let ownsLock = false;
146
+ try {
147
+ descriptor = openSync(lockPath, "wx", 0o600);
148
+ ownsLock = true;
149
+ fchmodSync(descriptor, 0o600);
150
+ writeFileSync(
151
+ descriptor,
152
+ `${JSON.stringify({
153
+ kind: "admin-pagination-lock",
154
+ pid: process.pid,
155
+ token,
156
+ operationFingerprint,
157
+ createdAt: new Date().toISOString(),
158
+ })}\n`,
159
+ );
160
+ fsyncSync(descriptor);
161
+ closeSync(descriptor);
162
+ descriptor = undefined;
163
+ // A stale-lock reclaimer can begin after the check above but before this
164
+ // O_EXCL create. It hard-links the exact old inode before removal. If
165
+ // that claim still exists, relinquish our new lock rather than allowing
166
+ // two owners to cross during the handoff.
167
+ if (pathEntryExists(reclaimPath)) {
168
+ releaseCheckpointLock({ lockPath, token });
169
+ ownsLock = false;
170
+ throw validationError(
171
+ `Another Lumine admin scan is recovering checkpoint ${checkpointPath}. Retry after it finishes.`,
172
+ );
173
+ }
174
+ return { lockPath, token };
175
+ } catch (error) {
176
+ if (descriptor !== undefined) {
177
+ try {
178
+ closeSync(descriptor);
179
+ } catch {
180
+ // The failed lock initialization may already have closed it.
181
+ }
182
+ }
183
+ if (ownsLock) {
184
+ try {
185
+ unlinkSync(lockPath);
186
+ } catch {
187
+ // A failed initialization may leave no owned lock to remove.
188
+ }
189
+ }
190
+ if (error?.code !== "EEXIST") throw error;
191
+ if (attempt > 0 || !removeDeadCheckpointLock(lockPath)) {
192
+ throw validationError(
193
+ `Another Lumine admin scan owns checkpoint ${checkpointPath}. Wait for it to finish or choose a different --checkpoint.`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+ throw validationError(
199
+ `Could not acquire the pagination checkpoint lock for ${checkpointPath}.`,
200
+ );
201
+ }
202
+
203
+ function removeDeadCheckpointLock(lockPath) {
204
+ const reclaimPath = `${lockPath}.reclaim`;
205
+ let ownsReclaim = false;
206
+ try {
207
+ // Creating a fixed-name hard link is an atomic claim on the exact lock
208
+ // inode. It serializes reclaimers and lets us prove we are not unlinking a
209
+ // replacement lock created after another process removed the stale one.
210
+ linkSync(lockPath, reclaimPath);
211
+ ownsReclaim = true;
212
+ const fileState = lstatSync(reclaimPath);
213
+ if (!fileState.isFile() || fileState.isSymbolicLink()) return false;
214
+ if (fileState.size > 16 * 1024) return false;
215
+ const state = JSON.parse(readFileSync(reclaimPath, "utf8"));
216
+ if (
217
+ state?.kind !== "admin-pagination-lock" ||
218
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
219
+ String(state?.token || ""),
220
+ ) ||
221
+ !/^[a-f0-9]{64}$/i.test(String(state?.operationFingerprint || ""))
222
+ ) {
223
+ return false;
224
+ }
225
+ const pid = Number(state?.pid);
226
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
227
+ try {
228
+ process.kill(pid, 0);
229
+ return false;
230
+ } catch (error) {
231
+ if (error?.code !== "ESRCH") return false;
232
+ }
233
+ const claimedState = lstatSync(reclaimPath);
234
+ const currentState = lstatSync(lockPath);
235
+ if (
236
+ claimedState.dev !== currentState.dev ||
237
+ claimedState.ino !== currentState.ino
238
+ ) {
239
+ return false;
240
+ }
241
+ unlinkSync(lockPath);
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ } finally {
246
+ if (ownsReclaim) {
247
+ try {
248
+ unlinkSync(reclaimPath);
249
+ } catch {
250
+ // A failed or completed reclaim leaves the current lock untouched.
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ function releaseCheckpointLock(lock) {
257
+ try {
258
+ const fileState = lstatSync(lock.lockPath);
259
+ if (!fileState.isFile() || fileState.isSymbolicLink()) return;
260
+ const saved = JSON.parse(readFileSync(lock.lockPath, "utf8"));
261
+ if (saved?.token === lock.token && Number(saved?.pid) === process.pid) {
262
+ unlinkSync(lock.lockPath);
263
+ }
264
+ } catch (error) {
265
+ if (error?.code !== "ENOENT") {
266
+ process.stderr.write(
267
+ `Lumine admin: could not release pagination lock ${lock.lockPath} (${error?.message || error}).\n`,
268
+ );
269
+ }
270
+ }
80
271
  }
81
272
 
82
273
  function operationFingerprintValue({
@@ -91,6 +282,13 @@ function operationFingerprintValue({
91
282
  apiUrl: String(options.apiUrl || "").replace(/\/$/, ""),
92
283
  name: operation.name,
93
284
  path: pathWithCursor(operation.path, ""),
285
+ ...(operation.name === "builds.candidates"
286
+ ? {
287
+ resultTransform: {
288
+ siteUrl: String(options.siteUrl || "").replace(/\/$/, ""),
289
+ },
290
+ }
291
+ : {}),
94
292
  pagination: {
95
293
  collectionKey: operation.pagination.collectionKey,
96
294
  coverageQueue: operation.pagination.coverageQueue || null,
@@ -141,9 +339,13 @@ function createCandidateSpool(checkpointPath) {
141
339
  function loadSupersededCandidateSpool(checkpointPath) {
142
340
  let saved;
143
341
  try {
144
- saved = readAdminJsonFile(checkpointPath, "the previous pagination checkpoint", {
145
- maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
146
- });
342
+ saved = readAdminJsonFile(
343
+ checkpointPath,
344
+ "the previous pagination checkpoint",
345
+ {
346
+ maxBytes: MAX_LEGACY_ADMIN_CHECKPOINT_BYTES,
347
+ },
348
+ );
147
349
  } catch {
148
350
  return null;
149
351
  }
@@ -215,8 +417,7 @@ function normalizeClientFilterSummary(value) {
215
417
  if (
216
418
  !Array.isArray(value.contentTypes) ||
217
419
  value.contentTypes.some(
218
- (contentType) =>
219
- typeof contentType !== "string" || !contentType.trim(),
420
+ (contentType) => typeof contentType !== "string" || !contentType.trim(),
220
421
  )
221
422
  ) {
222
423
  throw validationError(
@@ -324,7 +525,9 @@ function verifyCandidateSpool({
324
525
  try {
325
526
  fileState = lstatSync(spoolPath);
326
527
  } catch {
327
- throw validationError(`Could not read pagination candidates at ${spoolPath}.`);
528
+ throw validationError(
529
+ `Could not read pagination candidates at ${spoolPath}.`,
530
+ );
328
531
  }
329
532
  if (!fileState.isFile() || fileState.isSymbolicLink()) {
330
533
  throw validationError(
@@ -430,7 +633,9 @@ function serializedObjectEntries(value, excludedKeys = new Set()) {
430
633
  export async function forEachPaginatedResultItem(result, visit) {
431
634
  const storage = paginationStorageFromResult(result);
432
635
  if (!storage) {
433
- throw validationError("The result does not contain spooled pagination data.");
636
+ throw validationError(
637
+ "The result does not contain spooled pagination data.",
638
+ );
434
639
  }
435
640
  verifyCandidateSpool({ ...storage, discardUnconfirmedTail: false });
436
641
  const input = createReadStream(storage.spoolPath, { encoding: "utf8" });
@@ -604,22 +809,87 @@ function aggregatePageResult({ operation, lastPage, checkpointPath, state }) {
604
809
  });
605
810
  }
606
811
 
607
- export async function runAutomaticPagination({
812
+ export async function runAutomaticPagination(args) {
813
+ const { options, operation, runId } = args;
814
+ if (!operation.pagination) {
815
+ throw validationError(
816
+ "--all is supported only by paginated admin listings.",
817
+ );
818
+ }
819
+ const operationFingerprint = operationFingerprintValue({
820
+ schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
821
+ options,
822
+ operation,
823
+ runId,
824
+ });
825
+ const checkpointPath = resolveAutomaticCheckpointPath({
826
+ options,
827
+ operation,
828
+ runId,
829
+ operationFingerprint,
830
+ });
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);
845
+ try {
846
+ return await runAutomaticPaginationWithLock({
847
+ ...args,
848
+ checkpointPath,
849
+ operationFingerprint,
850
+ signal: controller.signal,
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;
874
+ } finally {
875
+ signalSource.removeListener("SIGINT", onSigint);
876
+ signalSource.removeListener("SIGTERM", onSigterm);
877
+ releaseCheckpointLock(lock);
878
+ }
879
+ }
880
+
881
+ async function runAutomaticPaginationWithLock({
608
882
  options,
609
883
  operation,
610
884
  runId,
885
+ checkpointPath,
886
+ operationFingerprint,
611
887
  fetchPage,
612
888
  transformPage,
613
889
  recordCoverage,
614
890
  reportProgress = (message) => process.stderr.write(`${message}\n`),
891
+ signal,
615
892
  }) {
616
- if (!operation.pagination) {
617
- throw validationError("--all is supported only by paginated admin listings.");
618
- }
619
- const checkpointPath = path.resolve(
620
- options.adminCheckpoint ||
621
- defaultCheckpointPath({ runId, operationName: operation.name }),
622
- );
623
893
  const requestedOutputPath = String(options.adminOutput || "").trim();
624
894
  if (options.adminOutput && !requestedOutputPath) {
625
895
  throw validationError("An output file path is required.");
@@ -632,12 +902,6 @@ export async function runAutomaticPagination({
632
902
  "The pagination checkpoint and result output must use different files.",
633
903
  );
634
904
  }
635
- const operationFingerprint = operationFingerprintValue({
636
- schemaVersion: ADMIN_PAGINATION_SCHEMA_VERSION,
637
- options,
638
- operation,
639
- runId,
640
- });
641
905
  const legacyOperationFingerprint = operationFingerprintValue({
642
906
  schemaVersion: LEGACY_ADMIN_PAGINATION_SCHEMA_VERSION,
643
907
  options,
@@ -774,15 +1038,12 @@ export async function runAutomaticPagination({
774
1038
  : null,
775
1039
  snapshotTimeStamp:
776
1040
  savedPages > 0
777
- ? normalizeBoundary(
778
- saved?.snapshotTimeStamp,
779
- "snapshotTimeStamp",
780
- )
1041
+ ? normalizeBoundary(saved?.snapshotTimeStamp, "snapshotTimeStamp")
781
1042
  : null,
782
1043
  after:
783
1044
  savedPages > 0
784
1045
  ? normalizeBoundary(saved?.after, "after")
785
- : operation.pagination.after ?? null,
1046
+ : (operation.pagination.after ?? null),
786
1047
  boundariesConfirmed: saved?.boundariesConfirmed,
787
1048
  candidateCount,
788
1049
  spoolPath: path.resolve(String(saved?.spoolPath || "")),
@@ -849,19 +1110,27 @@ export async function runAutomaticPagination({
849
1110
  }
850
1111
  let lastPage = null;
851
1112
  while (!state.exhausted) {
1113
+ signal?.throwIfAborted();
852
1114
  if (state.pages >= MAX_AUTOMATIC_PAGES) {
853
1115
  throw validationError(
854
1116
  `Automatic pagination stopped at the ${MAX_AUTOMATIC_PAGES}-page safety ceiling. Start a narrower request with a new checkpoint.`,
855
1117
  );
856
1118
  }
857
1119
  const requestPath = pathWithCursor(operation.path, state.nextCursor);
858
- const raw = await fetchPage(requestPath);
1120
+ const raw = await fetchPage(requestPath, signal);
1121
+ signal?.throwIfAborted();
859
1122
  const page = transformPage(raw);
860
1123
  lastPage = page;
861
1124
  const data = page?.data || {};
862
1125
  const pagination = data.pagination;
863
- if (!pagination || typeof pagination !== "object" || Array.isArray(pagination)) {
864
- 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
+ );
865
1134
  }
866
1135
  const items = Array.isArray(data[operation.pagination.collectionKey])
867
1136
  ? data[operation.pagination.collectionKey]
@@ -873,7 +1142,9 @@ export async function runAutomaticPagination({
873
1142
  }
874
1143
  const nextCursor = String(pagination.nextCursor || "");
875
1144
  if (nextCursor && nextCursor === state.nextCursor) {
876
- throw validationError("The API returned the same pagination cursor twice.");
1145
+ throw validationError(
1146
+ "The API returned the same pagination cursor twice.",
1147
+ );
877
1148
  }
878
1149
  if (!pagination.exhausted && !nextCursor) {
879
1150
  throw validationError(
@@ -895,7 +1166,11 @@ export async function runAutomaticPagination({
895
1166
  );
896
1167
  const pageAfter = normalizeBoundary(pagination.after, "after");
897
1168
  if (state.boundariesConfirmed) {
898
- assertStableBoundary("snapshotMaxId", state.snapshotMaxId, pageSnapshotMaxId);
1169
+ assertStableBoundary(
1170
+ "snapshotMaxId",
1171
+ state.snapshotMaxId,
1172
+ pageSnapshotMaxId,
1173
+ );
899
1174
  assertStableBoundary(
900
1175
  "snapshotTimeStamp",
901
1176
  state.snapshotTimeStamp,
@@ -903,7 +1178,10 @@ export async function runAutomaticPagination({
903
1178
  );
904
1179
  assertStableBoundary("after", state.after, pageAfter);
905
1180
  } else {
906
- const requestedAfter = normalizeBoundary(operation.pagination.after, "after");
1181
+ const requestedAfter = normalizeBoundary(
1182
+ operation.pagination.after,
1183
+ "after",
1184
+ );
907
1185
  if (requestedAfter !== null) {
908
1186
  assertStableBoundary("after", requestedAfter, pageAfter);
909
1187
  }
@@ -931,10 +1209,7 @@ export async function runAutomaticPagination({
931
1209
  pages: state.pages + 1,
932
1210
  scannedCount:
933
1211
  state.scannedCount +
934
- normalizeScannedCount(
935
- pagination.scannedCount,
936
- items.length,
937
- ),
1212
+ normalizeScannedCount(pagination.scannedCount, items.length),
938
1213
  candidateCount: state.candidateCount + items.length,
939
1214
  spoolBytes: state.spoolBytes + appended.addedBytes,
940
1215
  spoolSha256: appended.spoolSha256,
@@ -961,19 +1236,24 @@ export async function runAutomaticPagination({
961
1236
  checkpointPath,
962
1237
  state,
963
1238
  });
1239
+ signal?.throwIfAborted();
964
1240
  if (recordCoverage && operation.pagination.coverageQueue) {
965
- await recordCoverage({
966
- queue: operation.pagination.coverageQueue,
967
- mode: operation.pagination.coverageMode || "all",
968
- after: state.after,
969
- pages: state.pages,
970
- scannedCount: state.scannedCount,
971
- candidateCount: state.candidateCount,
972
- snapshotMaxId: state.snapshotMaxId,
973
- snapshotTimeStamp: state.snapshotTimeStamp,
974
- exhausted: state.exhausted,
975
- filters: operation.pagination.filters || {},
976
- });
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();
977
1257
  }
978
1258
  if (outputPath) {
979
1259
  result.data.scan.outputPath = outputPath;
@@ -1010,12 +1290,16 @@ export function readBatchSkipTargets({ filePath, parseTarget, defaultReason }) {
1010
1290
  throw validationError("The batch target file contains no targets.");
1011
1291
  }
1012
1292
  if (values.length > MAX_BATCH_TARGETS) {
1013
- 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
+ );
1014
1296
  }
1015
1297
  const deduped = new Map();
1016
1298
  for (const value of values) {
1017
1299
  const rawTarget =
1018
- typeof value === "string" ? value : value?.target || value?.url || value?.id;
1300
+ typeof value === "string"
1301
+ ? value
1302
+ : value?.target || value?.url || value?.id;
1019
1303
  const parsed = parseTarget({
1020
1304
  target: rawTarget,
1021
1305
  explicitType: typeof value === "object" ? value?.type || "" : "",
@@ -1082,7 +1366,9 @@ export async function runBatchSkips({
1082
1366
  !saved.completed ||
1083
1367
  typeof saved.completed !== "object"
1084
1368
  ) {
1085
- 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
+ );
1086
1372
  }
1087
1373
  state = saved;
1088
1374
  } else {
@@ -1123,8 +1409,9 @@ export async function runBatchSkips({
1123
1409
  batch: {
1124
1410
  targetCount: targets.length,
1125
1411
  completedCount: Object.keys(state.completed).length,
1126
- changedCount: Object.values(state.completed).filter((entry) => entry.changed)
1127
- .length,
1412
+ changedCount: Object.values(state.completed).filter(
1413
+ (entry) => entry.changed,
1414
+ ).length,
1128
1415
  checkpointPath,
1129
1416
  resumed: options.adminResume === true,
1130
1417
  targets: state.completed,
@@ -1132,10 +1419,14 @@ export async function runBatchSkips({
1132
1419
  },
1133
1420
  };
1134
1421
  if (options.adminOutput) {
1135
- result.data.batch.outputPath = writeAdminJsonFile(options.adminOutput, result, {
1136
- privateFile: true,
1137
- maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
1138
- });
1422
+ result.data.batch.outputPath = writeAdminJsonFile(
1423
+ options.adminOutput,
1424
+ result,
1425
+ {
1426
+ privateFile: true,
1427
+ maxBytes: MAX_BATCH_CHECKPOINT_BYTES,
1428
+ },
1429
+ );
1139
1430
  }
1140
1431
  return result;
1141
1432
  }