pixivflow 2.19.3 → 2.19.4

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.
@@ -59,6 +59,7 @@ const schedules_1 = require("../scheduler/schedules");
59
59
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
60
60
  const DeliveryLedgerPort_1 = require("../delivery/DeliveryLedgerPort");
61
61
  const OutboxWorker_1 = require("../delivery/OutboxWorker");
62
+ const settleDeliveryTerminal_1 = require("../delivery/settleDeliveryTerminal");
62
63
  const LegacyOutboxMigration_1 = require("../delivery/LegacyOutboxMigration");
63
64
  const NotificationPolicy_1 = require("../notification/NotificationPolicy");
64
65
  const node_crypto_1 = require("node:crypto");
@@ -241,22 +242,10 @@ async function createSchedulerRuntime(configPathArg) {
241
242
  const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
242
243
  retryBaseMs: config.delivery?.outboxRetryBaseMs,
243
244
  retryMaxMs: config.delivery?.outboxRetryMaxMs,
244
- // A confirmed ACK promotes the delivery_pending cell to submitted.
245
+ // A confirmed ACK settles the owning Slot cell (submitted / duplicate /
246
+ // failed); see settleDeliveryTerminal for the invariant it enforces.
245
247
  onDeliveryTerminal: (deliveryId, ack) => {
246
- const row = database.deliveries.getById(deliveryId);
247
- if (!row || !row.slotId || !row.targetId)
248
- return;
249
- if (ack.kind === 'duplicate_existing') {
250
- const coord = new SlotCoordinator_1.SlotCoordinator(database);
251
- coord.applyOutcome(row.slotId, row.targetId, {
252
- kind: 'duplicate',
253
- workId: row.pixivId,
254
- reason: 'downstream attested historical duplicate',
255
- });
256
- return;
257
- }
258
- const coord = new SlotCoordinator_1.SlotCoordinator(database);
259
- coord.markDelivered(row.slotId, row.targetId, row.pixivId, row.workType);
248
+ (0, settleDeliveryTerminal_1.settleDeliveryTerminal)(database, deliveryId, ack);
260
249
  },
261
250
  });
262
251
  const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
@@ -35,6 +35,20 @@ export type DeliveryAck = {
35
35
  matchedKey?: string;
36
36
  raw?: unknown;
37
37
  }
38
+ /**
39
+ * The provider accepted the request and persisted a record, but that record
40
+ * is in a TERMINAL FAILURE state (`failed`/`rejected`/`invalid`/`expired`).
41
+ * Nothing will ever be published from this intent, and because the provider
42
+ * keys its record by OUR idempotency_key, another attempt only returns the
43
+ * same failed record. Terminal: never retried, never reported as delivered.
44
+ */
45
+ | {
46
+ kind: 'remote_failed';
47
+ remoteId?: string;
48
+ remoteStatus: string;
49
+ error: string;
50
+ raw?: unknown;
51
+ }
38
52
  /** Transient failure: timeouts, 5xx, 429, connection errors. Retryable. */
39
53
  | {
40
54
  kind: 'retryable_failure';
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseDeliveryAck = parseDeliveryAck;
4
+ /** Downstream record states that will never become a published delivery. */
5
+ const TERMINAL_REMOTE_STATUSES = new Set(['failed', 'rejected', 'invalid', 'expired']);
4
6
  const DEFAULT_HINTS = {
5
7
  dataPath: 'data',
6
8
  idField: 'review_id',
@@ -49,6 +51,22 @@ function parseDeliveryAck(status, body, hints = {}) {
49
51
  const remoteId = typeof idValue === 'number' ? String(idValue) : asString(idValue);
50
52
  const remoteStatus = asString(rec[h.statusField]);
51
53
  const reused = rec[h.reusedField] === true;
54
+ // A 2xx envelope can still describe a RECORD THAT FAILED downstream: the
55
+ // provider persists the record before doing the real work, so "accepted" and
56
+ // "the persisted record is broken" arrive on the same HTTP status. The record
57
+ // is what gets published (and what a retry will find again), so its terminal
58
+ // state outranks the transport-level success of this request — otherwise a
59
+ // remote failure is recorded here as an end-to-end success.
60
+ const terminalStatus = remoteStatus?.trim().toLowerCase();
61
+ if (terminalStatus && TERMINAL_REMOTE_STATUSES.has(terminalStatus)) {
62
+ return {
63
+ kind: 'remote_failed',
64
+ remoteId,
65
+ remoteStatus: terminalStatus,
66
+ error: `delivery endpoint reported terminal status ${terminalStatus}`,
67
+ raw: body,
68
+ };
69
+ }
52
70
  if (!reused) {
53
71
  return { kind: 'accepted', remoteId, remoteStatus, raw: body };
54
72
  }
@@ -312,6 +312,19 @@ class OutboxWorker {
312
312
  });
313
313
  this.onDeliveryTerminal?.(row.deliveryId, ack, payload);
314
314
  break;
315
+ case 'remote_failed':
316
+ // The provider persisted the record, but it will never publish and the
317
+ // idempotency key pins us to that same record forever: retrying cannot
318
+ // change the outcome. Terminal, so the failure reaches the Slot instead
319
+ // of being masked as a delivered cell. Content is kept for inspection.
320
+ this.database.deliveries.recordAck(row.deliveryId, {
321
+ status: 'failed',
322
+ remoteId: ack.remoteId,
323
+ remoteStatus: ack.remoteStatus,
324
+ error: ack.error,
325
+ });
326
+ this.onDeliveryTerminal?.(row.deliveryId, ack, payload);
327
+ break;
315
328
  case 'permanent_failure':
316
329
  // Deterministic rejection: still retry a couple times to survive a
317
330
  // misconfigured blip, the outbox max-attempts then dead-letters it.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Terminal-ACK settlement: the ONE place a confirmed downstream delivery ACK
3
+ * becomes a Slot cell state.
4
+ *
5
+ * The outbox worker already recorded the durable delivery ledger outcome before
6
+ * calling here; this maps that ack onto the Slot FSM. Keeping it as a named,
7
+ * dependency-light function (instead of an inline closure in the scheduler
8
+ * runtime) is what makes the central invariant directly testable:
9
+ *
10
+ * REMOTE FAILURE MUST NOT BE REPORTED AS END-TO-END SUCCESS.
11
+ *
12
+ * A provider can answer HTTP 2xx while the record it persisted is terminally
13
+ * broken. The provider keys that record by OUR idempotency key, so a retry only
14
+ * returns the same broken record. Such an ack must settle the cell as `failed`,
15
+ * never promote it to `submitted` as though the content had been published.
16
+ */
17
+ import type { Database } from '../storage/Database';
18
+ import type { DeliveryAck } from './DeliveryAck';
19
+ /**
20
+ * Apply a terminal delivery ack to the Slot cell that owns the delivery intent.
21
+ * Returns false when the delivery has no Slot cell (ad-hoc / batch runs), which
22
+ * is a normal no-op rather than an error.
23
+ */
24
+ export declare function settleDeliveryTerminal(database: Database, deliveryId: string, ack: DeliveryAck): boolean;
25
+ //# sourceMappingURL=settleDeliveryTerminal.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.settleDeliveryTerminal = settleDeliveryTerminal;
4
+ const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
5
+ /**
6
+ * Apply a terminal delivery ack to the Slot cell that owns the delivery intent.
7
+ * Returns false when the delivery has no Slot cell (ad-hoc / batch runs), which
8
+ * is a normal no-op rather than an error.
9
+ */
10
+ function settleDeliveryTerminal(database, deliveryId, ack) {
11
+ const row = database.deliveries.getById(deliveryId);
12
+ if (!row || !row.slotId || !row.targetId)
13
+ return false;
14
+ const coord = new SlotCoordinator_1.SlotCoordinator(database);
15
+ if (ack.kind === 'duplicate_existing') {
16
+ // Historical duplicate: a record from a DIFFERENT intent already exists, so
17
+ // THIS intent published nothing. Never a successful submission.
18
+ coord.applyOutcome(row.slotId, row.targetId, {
19
+ kind: 'duplicate',
20
+ workId: row.pixivId,
21
+ reason: 'downstream attested historical duplicate',
22
+ });
23
+ return true;
24
+ }
25
+ if (ack.kind === 'remote_failed') {
26
+ // The downstream record is terminally broken, so nothing was published:
27
+ // settle the cell as failed instead of promoting it to submitted. Not
28
+ // retryable — the same idempotency key can only return that record.
29
+ coord.applyOutcome(row.slotId, row.targetId, {
30
+ kind: 'failed',
31
+ retryable: false,
32
+ error: `downstream reported terminal status ${ack.remoteStatus} (review ${ack.remoteId ?? 'unknown'})`,
33
+ });
34
+ return true;
35
+ }
36
+ // accepted / idempotent_replay: a confirmed ACK is the sole path to submitted.
37
+ coord.markDelivered(row.slotId, row.targetId, row.pixivId, row.workType);
38
+ return true;
39
+ }
40
+ //# sourceMappingURL=settleDeliveryTerminal.js.map
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.19.3",
4
+ "version": "2.19.4",
5
5
  "private": true
6
6
  }
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '2.19.3', commit: '699f027bb45a' };
5
+ exports.BUILD = { version: '2.19.4', commit: 'f331cd4bb164' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "2.19.3",
4
+ "version": "2.19.4",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "2.19.3",
3
+ "version": "2.19.4",
4
4
  "description": "🎨 智能的 Pixiv 自动化下载工具 - 支持批量下载插画和小说、定时任务、Docker部署 | Intelligent Pixiv Automation Downloader with batch download, scheduler, and Docker support",
5
5
  "repository": {
6
6
  "type": "git",