pixivflow 3.1.0 → 3.3.0

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.
Files changed (61) hide show
  1. package/README.md +26 -1
  2. package/dist/commands/SchedulerCommand.js +31 -51
  3. package/dist/commands/scheduler-runtime.js +47 -7
  4. package/dist/config/defaults.d.ts +2 -0
  5. package/dist/config/defaults.js +6 -0
  6. package/dist/config/types.d.ts +20 -0
  7. package/dist/config/validation.js +11 -0
  8. package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
  9. package/dist/delivery/DeliveryDispatcher.js +14 -0
  10. package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
  11. package/dist/delivery/EventCallbackDelivery.js +89 -0
  12. package/dist/delivery/OutboxWorker.d.ts +11 -0
  13. package/dist/delivery/OutboxWorker.js +51 -1
  14. package/dist/package.json +1 -1
  15. package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
  16. package/dist/scheduler/CandidateSearchParams.js +94 -0
  17. package/dist/scheduler/JobCancellation.d.ts +36 -0
  18. package/dist/scheduler/JobCancellation.js +73 -0
  19. package/dist/scheduler/JobEventStream.d.ts +94 -0
  20. package/dist/scheduler/JobEventStream.js +251 -0
  21. package/dist/scheduler/JobFacade.d.ts +298 -0
  22. package/dist/scheduler/JobFacade.js +622 -0
  23. package/dist/scheduler/JobProjection.d.ts +67 -0
  24. package/dist/scheduler/JobProjection.js +32 -0
  25. package/dist/scheduler/JobView.d.ts +34 -0
  26. package/dist/scheduler/JobView.js +82 -0
  27. package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
  28. package/dist/scheduler/ManualJobAdmission.js +310 -0
  29. package/dist/scheduler/ManualJobService.d.ts +57 -0
  30. package/dist/scheduler/ManualJobService.js +91 -0
  31. package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
  32. package/dist/scheduler/ManualRefetchAdapter.js +41 -0
  33. package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
  34. package/dist/scheduler/MultiScheduleManager.js +68 -6
  35. package/dist/scheduler/ProtocolErrors.d.ts +68 -0
  36. package/dist/scheduler/ProtocolErrors.js +94 -0
  37. package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
  38. package/dist/scheduler/ScheduleTriggerServer.js +166 -1
  39. package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
  40. package/dist/scheduler/SlotBusinessStatus.js +6 -0
  41. package/dist/scheduler/SlotCoordinator.d.ts +49 -5
  42. package/dist/scheduler/SlotCoordinator.js +89 -22
  43. package/dist/scheduler/StallSweep.d.ts +65 -0
  44. package/dist/scheduler/StallSweep.js +105 -0
  45. package/dist/scheduler/TargetOutcome.d.ts +39 -1
  46. package/dist/scheduler/TargetOutcome.js +51 -1
  47. package/dist/scheduler/ledger-time.d.ts +23 -0
  48. package/dist/scheduler/ledger-time.js +37 -0
  49. package/dist/storage/DatabaseMigration.js +48 -0
  50. package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
  51. package/dist/storage/repositories/DeliveryRepository.js +13 -0
  52. package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
  53. package/dist/storage/repositories/OutboxRepository.js +142 -0
  54. package/dist/storage/repositories/SlotRepository.d.ts +34 -0
  55. package/dist/storage/repositories/SlotRepository.js +61 -2
  56. package/dist/version.js +1 -1
  57. package/dist/webui/package.json +1 -1
  58. package/examples/gateway/README.md +4 -0
  59. package/examples/onebot-adapter/README.md +139 -0
  60. package/examples/onebot-adapter/server.mjs +612 -0
  61. package/package.json +2 -1
@@ -6,6 +6,7 @@ const node_crypto_1 = require("node:crypto");
6
6
  const promises_1 = require("node:fs/promises");
7
7
  const errorClass_1 = require("./errorClass");
8
8
  const redact_1 = require("../utils/redact");
9
+ const ProtocolErrors_1 = require("../scheduler/ProtocolErrors");
9
10
  const logger_1 = require("../logger");
10
11
  /** Exponential backoff with jitter, capped. */
11
12
  function backoffDelayMs(attempt, base, max) {
@@ -81,6 +82,10 @@ class OutboxWorker {
81
82
  break;
82
83
  let deferred = false;
83
84
  for (const row of rows) {
85
+ if (this.stopCancelledWork(row)) {
86
+ dead++;
87
+ continue;
88
+ }
84
89
  const readiness = await this.checkReadiness(row);
85
90
  if (!readiness.ready) {
86
91
  this.database.outbox.release(row.id);
@@ -114,6 +119,8 @@ class OutboxWorker {
114
119
  this.database.outbox.release(row.id);
115
120
  continue;
116
121
  }
122
+ if (this.stopCancelledWork(row))
123
+ continue;
117
124
  const readiness = await this.checkReadiness(row);
118
125
  if (!readiness.ready) {
119
126
  this.database.outbox.release(row.id);
@@ -138,6 +145,36 @@ class OutboxWorker {
138
145
  }
139
146
  return { ready: await this.dispatcher.isReady(row.deliveryTarget) };
140
147
  }
148
+ /**
149
+ * Honour a consumer cancel.
150
+ *
151
+ * A job cancelled while a delivery attempt was in flight (or waiting to
152
+ * retry) must not be delivered afterwards: the ledger's cell already carries
153
+ * the terminal `cancelled_by_consumer` verdict, so the intent behind this row
154
+ * is void. The row is dead-lettered rather than retried — no amount of
155
+ * retrying can make a cancelled delivery correct — and the skip is logged so
156
+ * the operator can see what did NOT happen.
157
+ */
158
+ stopCancelledWork(row) {
159
+ if (!row.deliveryId)
160
+ return false;
161
+ const delivery = this.database.deliveries.getById(row.deliveryId);
162
+ if (!delivery?.slotId || !delivery.targetId)
163
+ return false;
164
+ const cell = this.database.slots.getCell(delivery.slotId, delivery.targetId);
165
+ if (!cell || cell.status !== 'failed' || cell.terminalReasonCode !== ProtocolErrors_1.CANCELLED_BY_CONSUMER) {
166
+ return false;
167
+ }
168
+ const reason = 'work cancelled by consumer';
169
+ this.database.outbox.markDead(row.id, reason, Date.now());
170
+ logger_1.logger.warn('Delivery skipped: work cancelled by consumer', {
171
+ outboxId: row.id,
172
+ deliveryId: delivery.id,
173
+ slot: delivery.slotId,
174
+ target: delivery.targetId,
175
+ });
176
+ return true;
177
+ }
141
178
  /** Record at most one deferral per outbox row per 60s (cold-start poll guard). */
142
179
  recordDeferred(row, probe) {
143
180
  const now = Date.now();
@@ -223,6 +260,16 @@ class OutboxWorker {
223
260
  : undefined,
224
261
  });
225
262
  }
263
+ else if (row.kind === 'event_callback') {
264
+ // A `$defs/Event` posted to the Task's declared callback_url. It reuses
265
+ // the whole outbox contract: the idempotency key is the event_id, so a
266
+ // duplicate intent dedupes here and a retry re-sends the same event.
267
+ const payload = JSON.parse(row.payloadJson);
268
+ await this.dispatcher.deliverEventCallback(row.deliveryTarget, {
269
+ payload,
270
+ idempotencyKey: row.idempotencyKey ?? row.id,
271
+ });
272
+ }
226
273
  else {
227
274
  const payload = JSON.parse(row.payloadJson);
228
275
  const result = await this.dispatcher.deliver(row.deliveryTarget, {
@@ -258,7 +305,10 @@ class OutboxWorker {
258
305
  }
259
306
  catch (error) {
260
307
  const message = (0, redact_1.redactError)(error).slice(0, 1000);
261
- const { errorClass, retryable } = (0, errorClass_1.classifyError)(error);
308
+ // A provider that answers with an HTTP status is classified by that status
309
+ // (429/5xx retry, other 4xx dead-letter); transport errors classify from
310
+ // their message as before.
311
+ const { errorClass, retryable } = (0, errorClass_1.classifyError)(error, error?.status);
262
312
  // Deterministic failures cannot improve under the same idempotent intent.
263
313
  // Retrying them only leaves the owning Slot running until the budget expires.
264
314
  if (!retryable) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "3.1.0",
4
+ "version": "3.3.0",
5
5
  "private": true
6
6
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Occurrence-scoped retrieval view for `candidate_search` (§6 `$defs/CandidateSearchParams`).
3
+ *
4
+ * A generic job may narrow or redirect HOW it looks for a work — tags, expansion,
5
+ * scan depth, exclusions — but never WHAT it delivers: `applyCandidateSearchParams`
6
+ * is a pure function over a target snapshot that touches retrieval levers only.
7
+ * Delivery wiring, the target id and the plan identity are structurally out of
8
+ * reach here, which is why this mapping lives in one small pure module instead of
9
+ * being spread over the run path.
10
+ *
11
+ * The requested view is stored on the manual Slot (the same occurrence-scoped
12
+ * precedent as `recovery_mode`) so a worker that resumes a crashed job re-applies
13
+ * exactly the retrieval the requester asked for. Nothing here writes global
14
+ * config: a manual job can never change future scheduled runs.
15
+ */
16
+ import { TargetConfig } from '../config';
17
+ /** `$defs/CandidateSearchParams.constraints.exclude` item. */
18
+ export interface CandidateSearchExclude {
19
+ kind: 'work' | 'candidate' | 'tag';
20
+ id: string;
21
+ }
22
+ /** `$defs/CandidateSearchParams`. Only the retrieval/constraint part is modelled. */
23
+ export interface CandidateSearchParams {
24
+ source?: {
25
+ platform?: string;
26
+ account?: string;
27
+ };
28
+ query: {
29
+ tags: string[];
30
+ expand?: boolean;
31
+ };
32
+ constraints?: {
33
+ exclude?: CandidateSearchExclude[];
34
+ limit?: number;
35
+ scan_limit?: number;
36
+ work_types?: string[];
37
+ };
38
+ }
39
+ /** Scan limits are clamped to the same 1..100 window the config validation uses. */
40
+ export declare const CANDIDATE_SEARCH_SCAN_LIMIT_MAX = 100;
41
+ /**
42
+ * Project one target through a requested retrieval view (pure; never mutates the
43
+ * input or the global config).
44
+ *
45
+ * - `query.tags` becomes the tag expression (`tag` is the repo's space-joined
46
+ * multi-tag field).
47
+ * - `query.expand` maps to `tagRelation: 'or'`: for multiple tags that is exactly
48
+ * "recall expansion" (union instead of intersection), and for a single tag it
49
+ * is a no-op rather than an invented behaviour.
50
+ * - `constraints.limit` / `constraints.scan_limit` feed the existing per-target
51
+ * knobs, clamped to the validated window and never below `limit`.
52
+ *
53
+ * `constraints.exclude` and `constraints.work_types` are NOT applied here: an
54
+ * exclusion is a run-level duplicate filter (`excludedWorkIdsFromParams`) and a
55
+ * work-type restriction is checked against the resolved target by the admission,
56
+ * where the target is known.
57
+ */
58
+ export declare function applyCandidateSearchParams(target: TargetConfig, params: CandidateSearchParams): TargetConfig;
59
+ /**
60
+ * Map `constraints.exclude` onto the existing per-run duplicate history.
61
+ *
62
+ * A `kind: 'work'` exclusion is a work id the runner must not select. The ledger
63
+ * keys history by work type, and a protocol caller does not know the producer's
64
+ * work type, so the id is excluded for both types — narrowing, never widening.
65
+ */
66
+ export declare function excludedWorkIdsFromParams(params: CandidateSearchParams): {
67
+ illustration: string[];
68
+ novel: string[];
69
+ } | null;
70
+ /**
71
+ * Read the retrieval view back off a Slot row. Tolerant by design: a row written
72
+ * by an older build (or corrupted JSON) yields `null`, which means "run the plan
73
+ * exactly as configured" — the behaviour of every pre-existing slot.
74
+ */
75
+ export declare function parseCandidateSearchParamsJson(json: string | null | undefined): CandidateSearchParams | null;
76
+ //# sourceMappingURL=CandidateSearchParams.d.ts.map
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ /**
3
+ * Occurrence-scoped retrieval view for `candidate_search` (§6 `$defs/CandidateSearchParams`).
4
+ *
5
+ * A generic job may narrow or redirect HOW it looks for a work — tags, expansion,
6
+ * scan depth, exclusions — but never WHAT it delivers: `applyCandidateSearchParams`
7
+ * is a pure function over a target snapshot that touches retrieval levers only.
8
+ * Delivery wiring, the target id and the plan identity are structurally out of
9
+ * reach here, which is why this mapping lives in one small pure module instead of
10
+ * being spread over the run path.
11
+ *
12
+ * The requested view is stored on the manual Slot (the same occurrence-scoped
13
+ * precedent as `recovery_mode`) so a worker that resumes a crashed job re-applies
14
+ * exactly the retrieval the requester asked for. Nothing here writes global
15
+ * config: a manual job can never change future scheduled runs.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX = void 0;
19
+ exports.applyCandidateSearchParams = applyCandidateSearchParams;
20
+ exports.excludedWorkIdsFromParams = excludedWorkIdsFromParams;
21
+ exports.parseCandidateSearchParamsJson = parseCandidateSearchParamsJson;
22
+ /** Scan limits are clamped to the same 1..100 window the config validation uses. */
23
+ exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX = 100;
24
+ function clamp(value, min, max) {
25
+ return Math.max(min, Math.min(max, value));
26
+ }
27
+ /**
28
+ * Project one target through a requested retrieval view (pure; never mutates the
29
+ * input or the global config).
30
+ *
31
+ * - `query.tags` becomes the tag expression (`tag` is the repo's space-joined
32
+ * multi-tag field).
33
+ * - `query.expand` maps to `tagRelation: 'or'`: for multiple tags that is exactly
34
+ * "recall expansion" (union instead of intersection), and for a single tag it
35
+ * is a no-op rather than an invented behaviour.
36
+ * - `constraints.limit` / `constraints.scan_limit` feed the existing per-target
37
+ * knobs, clamped to the validated window and never below `limit`.
38
+ *
39
+ * `constraints.exclude` and `constraints.work_types` are NOT applied here: an
40
+ * exclusion is a run-level duplicate filter (`excludedWorkIdsFromParams`) and a
41
+ * work-type restriction is checked against the resolved target by the admission,
42
+ * where the target is known.
43
+ */
44
+ function applyCandidateSearchParams(target, params) {
45
+ const tags = params.query.tags;
46
+ const limit = params.constraints?.limit !== undefined ? clamp(params.constraints.limit, 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX) : target.limit;
47
+ const scanLimit = params.constraints?.scan_limit !== undefined
48
+ ? clamp(params.constraints.scan_limit, limit ?? 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX)
49
+ : target.candidateScanLimit;
50
+ return {
51
+ ...target,
52
+ tag: tags.join(' '),
53
+ ...(tags.length > 1 && params.query.expand === true ? { tagRelation: 'or' } : {}),
54
+ ...(limit !== undefined ? { limit } : {}),
55
+ ...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
56
+ };
57
+ }
58
+ /**
59
+ * Map `constraints.exclude` onto the existing per-run duplicate history.
60
+ *
61
+ * A `kind: 'work'` exclusion is a work id the runner must not select. The ledger
62
+ * keys history by work type, and a protocol caller does not know the producer's
63
+ * work type, so the id is excluded for both types — narrowing, never widening.
64
+ */
65
+ function excludedWorkIdsFromParams(params) {
66
+ const ids = (params.constraints?.exclude ?? [])
67
+ .filter((entry) => entry.kind === 'work')
68
+ .map((entry) => entry.id)
69
+ .filter((id) => id.length > 0);
70
+ if (ids.length === 0)
71
+ return null;
72
+ return { illustration: [...ids], novel: [...ids] };
73
+ }
74
+ /**
75
+ * Read the retrieval view back off a Slot row. Tolerant by design: a row written
76
+ * by an older build (or corrupted JSON) yields `null`, which means "run the plan
77
+ * exactly as configured" — the behaviour of every pre-existing slot.
78
+ */
79
+ function parseCandidateSearchParamsJson(json) {
80
+ if (!json)
81
+ return null;
82
+ try {
83
+ const parsed = JSON.parse(json);
84
+ if (!parsed || typeof parsed !== 'object')
85
+ return null;
86
+ if (!Array.isArray(parsed.query?.tags) || parsed.query.tags.length === 0)
87
+ return null;
88
+ return parsed;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ //# sourceMappingURL=CandidateSearchParams.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Consumer-initiated cancellation of one job, in ONE transaction.
3
+ *
4
+ * The protocol has no separate "cancelled" ledger state on purpose (§11.1: no
5
+ * new cell-FSM state may be invented). A cancel is therefore expressed with the
6
+ * existing terminal cell state: the cell becomes `failed` carrying the
7
+ * dedicated terminal reason `cancelled_by_consumer`, which the taxonomy treats
8
+ * as an operator/consumer stop rather than a Pixiv failure, and which the job
9
+ * facade surfaces as protocol `status: "cancelled"`.
10
+ *
11
+ * Stopping delivery is part of the same intent: every actionable outbox row for
12
+ * the cancelled cells is cancelled inside the transaction, and the delivery
13
+ * worker refuses to (re)attempt a delivery whose cell carries the cancel
14
+ * reason — a cancel that is undone by a retry would be worse than no cancel.
15
+ */
16
+ import type { Database } from '../storage/Database';
17
+ /** The user-facing business message for a consumer cancel. */
18
+ export declare const CANCELLED_BY_CONSUMER_MESSAGE = "\u4EFB\u52A1\u5DF2\u88AB\u53D6\u6D88";
19
+ export interface JobCancellationResult {
20
+ slotId: string;
21
+ /** True when THIS call performed the terminal transition. */
22
+ cancelled: boolean;
23
+ /** True when the work was already terminal before this call (idempotent replay). */
24
+ alreadyTerminal: boolean;
25
+ /** Outbox intents this call stopped from ever being attempted again. */
26
+ stoppedDeliveries: number;
27
+ }
28
+ /**
29
+ * Cancel one job (a slot) by id. Idempotent: a second call observes a terminal
30
+ * slot and reports `alreadyTerminal` without touching the ledger.
31
+ *
32
+ * A slot that already delivered something is never downgraded to `failed`; it
33
+ * keeps its delivered cell and rolls up as `partial`.
34
+ */
35
+ export declare function cancelConsumerJob(database: Database, slotId: string, now?: number): JobCancellationResult;
36
+ //# sourceMappingURL=JobCancellation.d.ts.map
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * Consumer-initiated cancellation of one job, in ONE transaction.
4
+ *
5
+ * The protocol has no separate "cancelled" ledger state on purpose (§11.1: no
6
+ * new cell-FSM state may be invented). A cancel is therefore expressed with the
7
+ * existing terminal cell state: the cell becomes `failed` carrying the
8
+ * dedicated terminal reason `cancelled_by_consumer`, which the taxonomy treats
9
+ * as an operator/consumer stop rather than a Pixiv failure, and which the job
10
+ * facade surfaces as protocol `status: "cancelled"`.
11
+ *
12
+ * Stopping delivery is part of the same intent: every actionable outbox row for
13
+ * the cancelled cells is cancelled inside the transaction, and the delivery
14
+ * worker refuses to (re)attempt a delivery whose cell carries the cancel
15
+ * reason — a cancel that is undone by a retry would be worse than no cancel.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.CANCELLED_BY_CONSUMER_MESSAGE = void 0;
19
+ exports.cancelConsumerJob = cancelConsumerJob;
20
+ const ProtocolErrors_1 = require("./ProtocolErrors");
21
+ const SlotStateMachine_1 = require("./SlotStateMachine");
22
+ /** The user-facing business message for a consumer cancel. */
23
+ exports.CANCELLED_BY_CONSUMER_MESSAGE = '任务已被取消';
24
+ /**
25
+ * Cancel one job (a slot) by id. Idempotent: a second call observes a terminal
26
+ * slot and reports `alreadyTerminal` without touching the ledger.
27
+ *
28
+ * A slot that already delivered something is never downgraded to `failed`; it
29
+ * keeps its delivered cell and rolls up as `partial`.
30
+ */
31
+ function cancelConsumerJob(database, slotId, now = Date.now()) {
32
+ return database.transaction(() => {
33
+ const slot = database.slots.getSlot(slotId);
34
+ if (!slot) {
35
+ throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
36
+ message: 'unknown job',
37
+ detail: { reason: 'unknown_job', job_id: slotId },
38
+ });
39
+ }
40
+ const cells = slot.targetIds
41
+ .map((targetId) => database.slots.getCell(slotId, targetId))
42
+ .filter((cell) => cell !== null);
43
+ const open = cells.filter((cell) => !SlotStateMachine_1.TERMINAL_CELL_STATES.has(cell.status));
44
+ if (open.length === 0) {
45
+ return {
46
+ slotId,
47
+ cancelled: false,
48
+ alreadyTerminal: true,
49
+ stoppedDeliveries: 0,
50
+ };
51
+ }
52
+ let stoppedDeliveries = 0;
53
+ for (const cell of open) {
54
+ for (const delivery of database.deliveries.listForSlotCell(slotId, cell.targetId)) {
55
+ if (!database.outbox.hasActionableDelivery(delivery.id))
56
+ continue;
57
+ const row = database.outbox.listForDeliveryIds([delivery.id]).get(delivery.id);
58
+ if (row && database.outbox.cancel(row.id, now))
59
+ stoppedDeliveries++;
60
+ }
61
+ database.slots.transitionCell(slotId, cell.targetId, 'failed', exports.CANCELLED_BY_CONSUMER_MESSAGE);
62
+ database.slots.setCellTerminalReason(slotId, cell.targetId, ProtocolErrors_1.CANCELLED_BY_CONSUMER, exports.CANCELLED_BY_CONSUMER_MESSAGE);
63
+ }
64
+ // Never report a slot with a confirmed delivery as failed.
65
+ const delivered = cells.some((cell) => cell.status === 'submitted');
66
+ database.slots.markSlotStatus(slotId, delivered ? 'partial' : 'failed', exports.CANCELLED_BY_CONSUMER_MESSAGE);
67
+ const lease = database.slots.getSlotLease(slotId);
68
+ if (lease.owner)
69
+ database.slots.releaseSlotLease(slotId, lease.owner);
70
+ return { slotId, cancelled: true, alreadyTerminal: false, stoppedDeliveries };
71
+ });
72
+ }
73
+ //# sourceMappingURL=JobCancellation.js.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The durable job event stream (Workflow Protocol v1 §events).
3
+ *
4
+ * The stream is a PROJECTION of `delivery_events` — the log the delivery
5
+ * subsystem already writes — plus ONE durable ack cursor
6
+ * (`job_event_cursors`). There is no second event queue and no second table
7
+ * family: `slot_id` is the job id, `ts` is the protocol `at`, and `event` is the
8
+ * internal kind translated by the single mapping in `JobFacade`.
9
+ *
10
+ * Three properties this module owns, because they are protocol obligations:
11
+ *
12
+ * 1. **A terminal job always has a terminal event.** `SlotCoordinator`'s
13
+ * `execution.summary` hook only fires for success/partial/failed, and
14
+ * cancelled/expired slots would never produce one. Instead of widening the
15
+ * slot/business-status logic, the lifecycle events are *reconciled from the
16
+ * durable projection*: whatever the ledger already implies must exist, is
17
+ * materialised exactly once (mirroring `reconcileScheduleSummaries`). The
18
+ * page is therefore never empty for a job that exists.
19
+ *
20
+ * 2. **Ack is monotonic, idempotent and side-effect free.** It writes one row
21
+ * in `job_event_cursors` and nothing else. An unknown or older cursor is a
22
+ * no-op that reports the durable state — never an error.
23
+ *
24
+ * 3. **A declared `callback_url` is never silently dropped.** The event row and
25
+ * its callback intent are committed in ONE transaction, and a sweep over
26
+ * durable rows repairs any job whose terminal event is still missing (for
27
+ * example because the process died mid-flight). Retry/dead-lettering stay in
28
+ * the existing outbox worker.
29
+ */
30
+ import { ProtocolAckResult, ProtocolEventPage } from './JobFacade';
31
+ import { JobViewDeps } from './JobView';
32
+ /** One page of events. Bounded so a long-running job cannot stream unbounded JSON. */
33
+ export declare const EVENTS_PAGE_LIMIT = 200;
34
+ /** Bounded callback budget: a flaky third-party URL must converge, not retry forever. */
35
+ export declare const EVENT_CALLBACK_MAX_ATTEMPTS = 8;
36
+ export interface JobEventQuery {
37
+ after: string | null;
38
+ unackedOnly: boolean;
39
+ }
40
+ export declare class JobEventStream {
41
+ private readonly deps;
42
+ constructor(deps: JobViewDeps);
43
+ private get db();
44
+ /**
45
+ * `$defs/EventPage`. Throws 404 for an unknown job, exactly like
46
+ * `GET /jobs/:jobId`.
47
+ */
48
+ page(jobId: string, query: JobEventQuery): ProtocolEventPage;
49
+ /**
50
+ * `$defs/AckResult`. `ack_through` is monotonic and idempotent: replaying it,
51
+ * or sending an unknown/older cursor, is a no-op that reports what is durably
52
+ * stored. Acking never touches the slot ledger.
53
+ */
54
+ ack(jobId: string, body: unknown): ProtocolAckResult;
55
+ /**
56
+ * Materialise every lifecycle event the durable ledger already implies, at
57
+ * most once each, and enqueue the matching callback in the same transaction.
58
+ * `callbackUrl` is only read when THIS call is the one that first records the
59
+ * job's request: a replay never rewrites the declared callback.
60
+ */
61
+ reconcile(jobId: string, callbackUrl?: string | null): void;
62
+ /**
63
+ * Repair sweep for jobs that declared a callback and have no durable terminal
64
+ * event yet, so a process that died before the terminal reconcile still
65
+ * delivers its terminal callback without waiting for a poll. Only jobs that
66
+ * already own a durable `job.requested` row are in scope: that is exactly the
67
+ * set whose callback endpoint is known, and re-running it can never queue a
68
+ * second copy (the event_id is the outbox idempotency key). A job that never
69
+ * recorded its request event is repaired on the consumer's next read instead.
70
+ * Returns how many jobs it looked at, so a caller can assert it drains.
71
+ */
72
+ reconcileOutstanding(limit?: number): number;
73
+ private ensure;
74
+ /** The `callback_url` a job declared, read from its durable request event. */
75
+ private declaredCallbackUrl;
76
+ private enqueueCallback;
77
+ private eventBodyFor;
78
+ private requireJob;
79
+ /**
80
+ * `?after=` is a "give me everything after this" hint. An unrecognised or
81
+ * foreign cursor cannot filter, so it is treated as absent and the consumer
82
+ * sees the whole stream again — `event_id` is the dedupe key, and at-least-once
83
+ * is the contract.
84
+ */
85
+ private resolveAfter;
86
+ /**
87
+ * Map an opaque `event_id` back to a durable position. Unknown ids, ids of
88
+ * other jobs and internal-only rows all resolve to null, which callers treat
89
+ * as "no-op" — never as an error.
90
+ */
91
+ private resolveAckTarget;
92
+ private durableProjectedPosition;
93
+ }
94
+ //# sourceMappingURL=JobEventStream.d.ts.map