@nodii/telemetry 0.13.0 → 0.14.1
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.
- package/dist/worker/checkpoint-store.d.ts +66 -0
- package/dist/worker/checkpoint-store.d.ts.map +1 -0
- package/dist/worker/checkpoint-store.js +131 -0
- package/dist/worker/checkpoint-store.js.map +1 -0
- package/dist/worker/dead-letter.d.ts +81 -0
- package/dist/worker/dead-letter.d.ts.map +1 -0
- package/dist/worker/dead-letter.js +165 -0
- package/dist/worker/dead-letter.js.map +1 -0
- package/dist/worker/dedupe-store.d.ts +46 -0
- package/dist/worker/dedupe-store.d.ts.map +1 -0
- package/dist/worker/dedupe-store.js +113 -0
- package/dist/worker/dedupe-store.js.map +1 -0
- package/dist/worker/index.d.ts +23 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +46 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/observability-hooks.d.ts +99 -0
- package/dist/worker/observability-hooks.d.ts.map +1 -0
- package/dist/worker/observability-hooks.js +189 -0
- package/dist/worker/observability-hooks.js.map +1 -0
- package/dist/worker/outbox-dispatcher.d.ts +106 -0
- package/dist/worker/outbox-dispatcher.d.ts.map +1 -0
- package/dist/worker/outbox-dispatcher.js +285 -0
- package/dist/worker/outbox-dispatcher.js.map +1 -0
- package/dist/worker/replication-worker.d.ts +170 -0
- package/dist/worker/replication-worker.d.ts.map +1 -0
- package/dist/worker/replication-worker.js +576 -0
- package/dist/worker/replication-worker.js.map +1 -0
- package/dist/worker/signal-drain.d.ts +83 -0
- package/dist/worker/signal-drain.d.ts.map +1 -0
- package/dist/worker/signal-drain.js +131 -0
- package/dist/worker/signal-drain.js.map +1 -0
- package/dist/worker/single-active-lease.d.ts +180 -0
- package/dist/worker/single-active-lease.d.ts.map +1 -0
- package/dist/worker/single-active-lease.js +394 -0
- package/dist/worker/single-active-lease.js.map +1 -0
- package/dist/worker/streams-worker.d.ts +256 -0
- package/dist/worker/streams-worker.d.ts.map +1 -0
- package/dist/worker/streams-worker.js +621 -0
- package/dist/worker/streams-worker.js.map +1 -0
- package/dist/worker/sweeper-worker.d.ts +107 -0
- package/dist/worker/sweeper-worker.d.ts.map +1 -0
- package/dist/worker/sweeper-worker.js +245 -0
- package/dist/worker/sweeper-worker.js.map +1 -0
- package/dist/worker/tx.d.ts +39 -0
- package/dist/worker/tx.d.ts.map +1 -0
- package/dist/worker/tx.js +51 -0
- package/dist/worker/tx.js.map +1 -0
- package/dist/worker/worker-handle.d.ts +94 -0
- package/dist/worker/worker-handle.d.ts.map +1 -0
- package/dist/worker/worker-handle.js +15 -0
- package/dist/worker/worker-handle.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { SqlExecutor } from "./tx.js";
|
|
2
|
+
/** Default table name for the generalized checkpoint cursor. */
|
|
3
|
+
export declare const DEFAULT_CHECKPOINT_TABLE = "worker_checkpoint";
|
|
4
|
+
/** The durable checkpoint a worker resumes from. */
|
|
5
|
+
export interface Checkpoint {
|
|
6
|
+
subscription: string;
|
|
7
|
+
/** Redis-Streams id of the last event committed, or null if none yet. */
|
|
8
|
+
lastStreamId: string | null;
|
|
9
|
+
/** Optional LWW entity_version HWM (per D402 "+ optional"); null when unused. */
|
|
10
|
+
highWaterEntityVersion: bigint | null;
|
|
11
|
+
/** Count of events committed against this subscription. */
|
|
12
|
+
consumedCount: bigint;
|
|
13
|
+
}
|
|
14
|
+
/** The cursor position to advance to inside the apply tx. */
|
|
15
|
+
export interface CheckpointAdvance {
|
|
16
|
+
subscription: string;
|
|
17
|
+
/** The Redis-Streams id of the event being committed in this apply tx. */
|
|
18
|
+
lastStreamId: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional LWW entity_version high-water-mark. When provided the cursor only
|
|
21
|
+
* advances `high_water_entity_version` MONOTONICALLY (GREATEST of old/new) so
|
|
22
|
+
* an out-of-order replay cannot regress it. Omit when the subscription
|
|
23
|
+
* doesn't track an entity-version HWM.
|
|
24
|
+
*/
|
|
25
|
+
highWaterEntityVersion?: bigint | null;
|
|
26
|
+
/** How much to bump `consumed_count` by (default 1). */
|
|
27
|
+
incrementBy?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Idempotent DDL to create the generalized checkpoint table. */
|
|
30
|
+
export declare function checkpointTableDDL(table?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Durable HWM cursor store. ONE instance per subscription family (or share one
|
|
33
|
+
* across subscriptions — the `subscription` key disambiguates). The
|
|
34
|
+
* `advance(...)` write joins the caller's apply transaction via the injected
|
|
35
|
+
* `tx`; the store has NO pool of its own and cannot commit independently.
|
|
36
|
+
*/
|
|
37
|
+
export declare class CheckpointStore {
|
|
38
|
+
readonly table: string;
|
|
39
|
+
private readonly upsertSql;
|
|
40
|
+
private readonly selectSql;
|
|
41
|
+
constructor(opts?: {
|
|
42
|
+
table?: string;
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Read the durable checkpoint for a subscription, or `null` if none exists
|
|
46
|
+
* yet (cold start → the worker bootstraps from the snapshot per
|
|
47
|
+
* 09-replication § 7, then stitches to live from `lastStreamId`).
|
|
48
|
+
*
|
|
49
|
+
* `tx` may be a transaction handle OR a pooled connection — a read does not
|
|
50
|
+
* need to join the apply tx, but accepting the same seam keeps the API
|
|
51
|
+
* uniform and lets the caller read its OWN uncommitted advance inside a tx if
|
|
52
|
+
* it wants to.
|
|
53
|
+
*/
|
|
54
|
+
get(subscription: string, tx: SqlExecutor): Promise<Checkpoint | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Advance the durable cursor INSIDE the caller's apply transaction. MUST be
|
|
57
|
+
* called on the SAME `tx` the caller runs its projection upsert + dedupe
|
|
58
|
+
* record on, so cursor + projection + dedupe commit together-or-none. This is
|
|
59
|
+
* the atomic-in-apply-tx guarantee D402 pins.
|
|
60
|
+
*
|
|
61
|
+
* @param adv the new cursor position (the event id being committed).
|
|
62
|
+
* @param tx the caller's transaction handle (NOT a fresh pool conn).
|
|
63
|
+
*/
|
|
64
|
+
advance(adv: CheckpointAdvance, tx: SqlExecutor): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=checkpoint-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoint-store.d.ts","sourceRoot":"","sources":["../../src/worker/checkpoint-store.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,WAAW,EAAU,MAAM,SAAS,CAAC;AAYnD,gEAAgE;AAChE,eAAO,MAAM,wBAAwB,sBAAsB,CAAC;AAE5D,oDAAoD;AACpD,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,iFAAiF;IACjF,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,2DAA2D;IAC3D,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,KAAK,SAA2B,GAAG,MAAM,CAS3E;AAcD;;;;;GAKG;AACH,qBAAa,eAAe;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO;IAsBzC;;;;;;;;;OASG;IACG,GAAG,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAY5E;;;;;;;;OAQG;IACG,OAAO,CAAC,GAAG,EAAE,iBAAiB,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CActE"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// D402 — CheckpointStore: the durable high-water-mark (HWM) cursor primitive.
|
|
2
|
+
//
|
|
3
|
+
// D402 (LOCKED):
|
|
4
|
+
// "CheckpointStore (HWM cursor) — durable `last_stream_id` per subscription;
|
|
5
|
+
// advanced ATOMICALLY inside the apply transaction (cursor + projection
|
|
6
|
+
// upsert + dedupe commit together or none); snapshot→live 'stitch' per
|
|
7
|
+
// 09-replication-doctrine § 7."
|
|
8
|
+
//
|
|
9
|
+
// This GENERALIZES the two hand-rolled cursors that exist today (the task's
|
|
10
|
+
// instruction — "generalize, don't duplicate the table"):
|
|
11
|
+
// - replica-consumer's `replication_cursor` keyed by `source_stream`
|
|
12
|
+
// (ts/replica-consumer/src/sql-builders.ts CURSOR_UPSERT_SQL).
|
|
13
|
+
// - outbox-dispatcher's in-memory `HwmTracker` (ts/outbox-dispatcher/src/hwm.ts).
|
|
14
|
+
//
|
|
15
|
+
// THE LOAD-BEARING CORRECTNESS PROPERTY — atomic-in-apply-tx:
|
|
16
|
+
// `advance(...)` runs on a `tx` handle the CALLER owns. The store NEVER opens
|
|
17
|
+
// its own connection for the cursor write, so it physically cannot commit the
|
|
18
|
+
// cursor independently of the projection. The caller wraps projection upsert +
|
|
19
|
+
// `dedupe.record(...)` + `checkpoint.advance(...)` in ONE transaction; a
|
|
20
|
+
// failure anywhere rolls back ALL THREE (cursor does NOT advance past an
|
|
21
|
+
// un-applied event → no silent gap, no double-apply on retry).
|
|
22
|
+
//
|
|
23
|
+
// Table shape (generalized `replication_cursor`, keyed by `subscription`):
|
|
24
|
+
// subscription TEXT PRIMARY KEY -- the per-subscription key
|
|
25
|
+
// last_stream_id TEXT NOT NULL -- Redis-Streams id of last applied
|
|
26
|
+
// high_water_entity_version BIGINT -- optional LWW pointer (nullable)
|
|
27
|
+
// consumed_count BIGINT NOT NULL DEFAULT 0
|
|
28
|
+
// updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
29
|
+
/** Strict SQL identifier whitelist (mirrors replica-consumer's quoteIdent). */
|
|
30
|
+
const IDENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
31
|
+
function quoteIdent(name) {
|
|
32
|
+
if (!IDENT_PATTERN.test(name)) {
|
|
33
|
+
throw new Error(`invalid SQL identifier: ${name}`);
|
|
34
|
+
}
|
|
35
|
+
return `"${name}"`;
|
|
36
|
+
}
|
|
37
|
+
/** Default table name for the generalized checkpoint cursor. */
|
|
38
|
+
export const DEFAULT_CHECKPOINT_TABLE = "worker_checkpoint";
|
|
39
|
+
/** Idempotent DDL to create the generalized checkpoint table. */
|
|
40
|
+
export function checkpointTableDDL(table = DEFAULT_CHECKPOINT_TABLE) {
|
|
41
|
+
const t = quoteIdent(table);
|
|
42
|
+
return `CREATE TABLE IF NOT EXISTS ${t} (
|
|
43
|
+
subscription TEXT PRIMARY KEY,
|
|
44
|
+
last_stream_id TEXT,
|
|
45
|
+
high_water_entity_version BIGINT,
|
|
46
|
+
consumed_count BIGINT NOT NULL DEFAULT 0,
|
|
47
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
48
|
+
)`;
|
|
49
|
+
}
|
|
50
|
+
function toBigintOrNull(v) {
|
|
51
|
+
if (v === null || v === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
return BigInt(v);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Durable HWM cursor store. ONE instance per subscription family (or share one
|
|
57
|
+
* across subscriptions — the `subscription` key disambiguates). The
|
|
58
|
+
* `advance(...)` write joins the caller's apply transaction via the injected
|
|
59
|
+
* `tx`; the store has NO pool of its own and cannot commit independently.
|
|
60
|
+
*/
|
|
61
|
+
export class CheckpointStore {
|
|
62
|
+
table;
|
|
63
|
+
upsertSql;
|
|
64
|
+
selectSql;
|
|
65
|
+
constructor(opts = {}) {
|
|
66
|
+
this.table = opts.table ?? DEFAULT_CHECKPOINT_TABLE;
|
|
67
|
+
const t = quoteIdent(this.table);
|
|
68
|
+
// ON CONFLICT upsert: advance the cursor + bump consumed_count.
|
|
69
|
+
// `high_water_entity_version` advances MONOTONICALLY (GREATEST) so an
|
|
70
|
+
// out-of-order apply can't regress the LWW HWM; a NULL incoming value keeps
|
|
71
|
+
// the existing one (COALESCE on EXCLUDED).
|
|
72
|
+
this.upsertSql = `INSERT INTO ${t}
|
|
73
|
+
(subscription, last_stream_id, high_water_entity_version, consumed_count, updated_at)
|
|
74
|
+
VALUES ($1, $2, $3, $4, now())
|
|
75
|
+
ON CONFLICT (subscription) DO UPDATE SET
|
|
76
|
+
last_stream_id = EXCLUDED.last_stream_id,
|
|
77
|
+
high_water_entity_version = GREATEST(
|
|
78
|
+
COALESCE(${t}.high_water_entity_version, EXCLUDED.high_water_entity_version, 0),
|
|
79
|
+
COALESCE(EXCLUDED.high_water_entity_version, ${t}.high_water_entity_version, 0)
|
|
80
|
+
),
|
|
81
|
+
consumed_count = ${t}.consumed_count + EXCLUDED.consumed_count,
|
|
82
|
+
updated_at = now()`;
|
|
83
|
+
this.selectSql = `SELECT subscription, last_stream_id, high_water_entity_version, consumed_count
|
|
84
|
+
FROM ${t} WHERE subscription = $1`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read the durable checkpoint for a subscription, or `null` if none exists
|
|
88
|
+
* yet (cold start → the worker bootstraps from the snapshot per
|
|
89
|
+
* 09-replication § 7, then stitches to live from `lastStreamId`).
|
|
90
|
+
*
|
|
91
|
+
* `tx` may be a transaction handle OR a pooled connection — a read does not
|
|
92
|
+
* need to join the apply tx, but accepting the same seam keeps the API
|
|
93
|
+
* uniform and lets the caller read its OWN uncommitted advance inside a tx if
|
|
94
|
+
* it wants to.
|
|
95
|
+
*/
|
|
96
|
+
async get(subscription, tx) {
|
|
97
|
+
const res = await tx.query(this.selectSql, [subscription]);
|
|
98
|
+
const row = res.rows[0];
|
|
99
|
+
if (!row)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
subscription: row.subscription,
|
|
103
|
+
lastStreamId: row.last_stream_id,
|
|
104
|
+
highWaterEntityVersion: toBigintOrNull(row.high_water_entity_version),
|
|
105
|
+
consumedCount: BigInt(row.consumed_count),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Advance the durable cursor INSIDE the caller's apply transaction. MUST be
|
|
110
|
+
* called on the SAME `tx` the caller runs its projection upsert + dedupe
|
|
111
|
+
* record on, so cursor + projection + dedupe commit together-or-none. This is
|
|
112
|
+
* the atomic-in-apply-tx guarantee D402 pins.
|
|
113
|
+
*
|
|
114
|
+
* @param adv the new cursor position (the event id being committed).
|
|
115
|
+
* @param tx the caller's transaction handle (NOT a fresh pool conn).
|
|
116
|
+
*/
|
|
117
|
+
async advance(adv, tx) {
|
|
118
|
+
const inc = adv.incrementBy ?? 1;
|
|
119
|
+
const hwev = adv.highWaterEntityVersion === undefined ||
|
|
120
|
+
adv.highWaterEntityVersion === null
|
|
121
|
+
? null
|
|
122
|
+
: adv.highWaterEntityVersion.toString();
|
|
123
|
+
await tx.query(this.upsertSql, [
|
|
124
|
+
adv.subscription,
|
|
125
|
+
adv.lastStreamId,
|
|
126
|
+
hwev,
|
|
127
|
+
inc,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=checkpoint-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoint-store.js","sourceRoot":"","sources":["../../src/worker/checkpoint-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SqlExecutor } from "./tx.js";
|
|
2
|
+
/**
|
|
3
|
+
* A poison event handed to a dead-letter sink after exhausting in-line retries.
|
|
4
|
+
* Carries enough to make it REPLAYABLE (mirrors replica-consumer's DLQ envelope
|
|
5
|
+
* columns) plus the failure cause.
|
|
6
|
+
*/
|
|
7
|
+
export interface DeadLetterItem {
|
|
8
|
+
/** Source stream the event came off (the subscription / topic stream). */
|
|
9
|
+
sourceStream: string;
|
|
10
|
+
/** The outbox topic. */
|
|
11
|
+
topic: string;
|
|
12
|
+
/** The event's dedupe id (event_id). */
|
|
13
|
+
eventId: string;
|
|
14
|
+
/**
|
|
15
|
+
* The event payload — possibly containing PII. The redaction hook scrubs this
|
|
16
|
+
* BEFORE it reaches the sink (fail-closed). The sink NEVER sees raw PII.
|
|
17
|
+
*/
|
|
18
|
+
payload: Record<string, unknown>;
|
|
19
|
+
/** Why it was dead-lettered (the terminal error message). */
|
|
20
|
+
errorMessage: string;
|
|
21
|
+
/** How many in-line retries were attempted before giving up. */
|
|
22
|
+
retryCount: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The injected dead-letter sink. The caller provides the impl; there is NO
|
|
26
|
+
* default. `deadLetter(item)` MUST be durable / loud — a dead item must never
|
|
27
|
+
* vanish silently.
|
|
28
|
+
*/
|
|
29
|
+
export interface DeadLetterSink {
|
|
30
|
+
deadLetter(item: DeadLetterItem): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A minimal logger seam for the loud sink (structurally satisfied by the
|
|
34
|
+
* telemetry `logger` or `console`). Only `error` is required.
|
|
35
|
+
*/
|
|
36
|
+
export interface DeadLetterLogger {
|
|
37
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
38
|
+
}
|
|
39
|
+
export declare function withPiiRedaction(sink: DeadLetterSink, opts?: {
|
|
40
|
+
extraPaths?: readonly string[];
|
|
41
|
+
}): DeadLetterSink;
|
|
42
|
+
/**
|
|
43
|
+
* The FAIL-CLOSED-BY-DEFAULT wrapper every worker applies to its injected
|
|
44
|
+
* DeadLetterSink: redaction runs on every dead item WITHOUT the consumer having
|
|
45
|
+
* to remember to wrap (D15: the SAFE state must be the default). An
|
|
46
|
+
* already-wrapped sink is returned as-is (no double-wrap). Used internally by the
|
|
47
|
+
* worker constructors.
|
|
48
|
+
*/
|
|
49
|
+
export declare function ensurePiiRedaction(sink: DeadLetterSink): DeadLetterSink;
|
|
50
|
+
/**
|
|
51
|
+
* Reference sink #1: accepts the dead item but logs it LOUDLY (error-level,
|
|
52
|
+
* with the failure cause + identity, never the raw payload — that's already
|
|
53
|
+
* redacted by `withPiiRedaction`). It does NOT persist. Useful as an explicit
|
|
54
|
+
* "I have NO durable DLQ yet but I refuse to silently drop" choice. NEVER
|
|
55
|
+
* auto-wired — the caller must construct + inject it.
|
|
56
|
+
*/
|
|
57
|
+
export declare class LoudNoopDeadLetterSink implements DeadLetterSink {
|
|
58
|
+
private readonly logger;
|
|
59
|
+
constructor(logger: DeadLetterLogger);
|
|
60
|
+
deadLetter(item: DeadLetterItem): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Reference sink #2: persists the dead item into a CONSUMER-OWNED quarantine
|
|
64
|
+
* table the caller opts into (passing its OWN transaction/pool executor + table
|
|
65
|
+
* name). This is the SUBSCRIBER's own DLQ — NOT a source-side `outbox_dlq`
|
|
66
|
+
* (which D401 forbids). The caller controls the executor, so the write joins
|
|
67
|
+
* whatever transaction the caller chooses (or runs standalone on a pool conn).
|
|
68
|
+
*/
|
|
69
|
+
export declare class TableDeadLetterSink implements DeadLetterSink {
|
|
70
|
+
readonly table: string;
|
|
71
|
+
private readonly insertSql;
|
|
72
|
+
private readonly exec;
|
|
73
|
+
constructor(opts: {
|
|
74
|
+
executor: SqlExecutor;
|
|
75
|
+
table?: string;
|
|
76
|
+
});
|
|
77
|
+
/** Idempotent DDL for the consumer-owned quarantine table. */
|
|
78
|
+
static tableDDL(table?: string): string;
|
|
79
|
+
deadLetter(item: DeadLetterItem): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=dead-letter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dead-letter.d.ts","sourceRoot":"","sources":["../../src/worker/dead-letter.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAY3C;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,gEAAgE;IAChE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5D;AAqBD,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,cAAc,EACpB,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAC5C,cAAc,CA6BhB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CAGvE;AAED;;;;;;GAMG;AACH,qBAAa,sBAAuB,YAAW,cAAc;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,gBAAgB;IAE/C,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAWtD;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,YAAW,cAAc;IACxD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;gBAEvB,IAAI,EAAE;QAAE,QAAQ,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE;IAS3D,8DAA8D;IAC9D,MAAM,CAAC,QAAQ,CAAC,KAAK,SAAuB,GAAG,MAAM;IAc/C,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAUtD"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// D402 — DeadLetter: an INJECTED dead-letter sink with a fail-closed PII
|
|
2
|
+
// redaction hook.
|
|
3
|
+
//
|
|
4
|
+
// D402 (LOCKED):
|
|
5
|
+
// "DeadLetter — an injected sink (NEVER a default-on source-side `outbox_dlq`
|
|
6
|
+
// — see D-NEW / D401)."
|
|
7
|
+
//
|
|
8
|
+
// D401 forbids a default-on source-side DLQ table. So this primitive ships:
|
|
9
|
+
// 1. The `DeadLetterSink` INTERFACE — the caller injects the impl.
|
|
10
|
+
// 2. A fail-closed PII redaction wrapper AUTOMATICALLY applied by every worker
|
|
11
|
+
// constructor (via `ensurePiiRedaction`) to every dead item BEFORE it
|
|
12
|
+
// reaches the sink — fail-closed BY DEFAULT (D15: the SAFE state is the
|
|
13
|
+
// default; a consumer cannot leak raw PII by forgetting to wrap). If
|
|
14
|
+
// redaction throws, the payload is dropped to `[REDACTED]`, never raw. A
|
|
15
|
+
// consumer who wraps manually with `withPiiRedaction` is not double-wrapped.
|
|
16
|
+
// 3. A couple of REFERENCE impls the caller OPTS INTO:
|
|
17
|
+
// - `LoudNoopDeadLetterSink` — accepts the item but logs LOUDLY (never a
|
|
18
|
+
// silent drop). Only constructed when EXPLICITLY injected; never
|
|
19
|
+
// auto-wired anywhere.
|
|
20
|
+
// - `TableDeadLetterSink` — persists into a CONSUMER-OWNED table the
|
|
21
|
+
// caller opts into by passing its own `SqlExecutor` + table name. NOT a
|
|
22
|
+
// source-side `outbox_dlq`; it is the subscriber's own quarantine.
|
|
23
|
+
//
|
|
24
|
+
// There is NO default sink. A worker constructed without a DeadLetter has no
|
|
25
|
+
// dead-letter behavior; the caller must inject one to enable it. This is the
|
|
26
|
+
// D401/D402 "never a default-on" rule expressed in the type system.
|
|
27
|
+
import { attemptRedactString, redactFields } from "../redaction.js";
|
|
28
|
+
/** Strict SQL identifier whitelist (mirrors replica-consumer's quoteIdent). */
|
|
29
|
+
const IDENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
|
+
function quoteIdent(name) {
|
|
31
|
+
if (!IDENT_PATTERN.test(name)) {
|
|
32
|
+
throw new Error(`invalid SQL identifier: ${name}`);
|
|
33
|
+
}
|
|
34
|
+
return `"${name}"`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a sink with the FAIL-CLOSED PII redaction hook. Both the item's
|
|
38
|
+
* `payload` AND its `errorMessage` are scrubbed before they reach the wrapped
|
|
39
|
+
* sink:
|
|
40
|
+
* - `payload` runs through the canonical D15 `redactFields` redactor; if it
|
|
41
|
+
* THROWS (e.g. a circular/non-serializable value) the whole payload is
|
|
42
|
+
* collapsed to `{ redaction: "[REDACTED]" }` rather than passed through raw.
|
|
43
|
+
* - `errorMessage` runs through the canonical D15 string redactor
|
|
44
|
+
* (`attemptRedactString`) — real apply errors embed PII routinely (a
|
|
45
|
+
* postgres `Key (email)=(user@example.com) already exists`, an HTTP error
|
|
46
|
+
* carrying a URL+querystring); if the scrub fails it collapses to
|
|
47
|
+
* `[REDACTED]`. Fail-closed, never fail-open, on BOTH fields.
|
|
48
|
+
*
|
|
49
|
+
* Every worker that injects a DeadLetterSink gets this wrapper applied so a
|
|
50
|
+
* dead item can never carry plaintext PII into a quarantine table or a log.
|
|
51
|
+
*/
|
|
52
|
+
/** Brand marking a sink as already PII-redacting (so it is not double-wrapped). */
|
|
53
|
+
const PII_REDACTING = Symbol.for("nodii.worker.piiRedactingSink");
|
|
54
|
+
export function withPiiRedaction(sink, opts = {}) {
|
|
55
|
+
const wrapped = {
|
|
56
|
+
async deadLetter(item) {
|
|
57
|
+
let safePayload;
|
|
58
|
+
try {
|
|
59
|
+
safePayload = redactFields(item.payload ?? {}, {
|
|
60
|
+
extraPaths: opts.extraPaths,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Fail CLOSED: redaction failed → drop the payload entirely rather than
|
|
65
|
+
// risk leaking raw PII into the sink.
|
|
66
|
+
safePayload = { redaction: "[REDACTED]" };
|
|
67
|
+
}
|
|
68
|
+
// Scrub the error string too — a raw apply error routinely quotes PII.
|
|
69
|
+
// attemptRedactString is itself fail-closed (returns ok:false on throw);
|
|
70
|
+
// on a non-ok result we drop the whole string to [REDACTED].
|
|
71
|
+
const scrubbed = attemptRedactString(item.errorMessage ?? "", {
|
|
72
|
+
extraPaths: opts.extraPaths,
|
|
73
|
+
});
|
|
74
|
+
const safeErrorMessage = scrubbed.ok ? scrubbed.redacted : "[REDACTED]";
|
|
75
|
+
await sink.deadLetter({
|
|
76
|
+
...item,
|
|
77
|
+
payload: safePayload,
|
|
78
|
+
errorMessage: safeErrorMessage,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
wrapped[PII_REDACTING] = true;
|
|
83
|
+
return wrapped;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The FAIL-CLOSED-BY-DEFAULT wrapper every worker applies to its injected
|
|
87
|
+
* DeadLetterSink: redaction runs on every dead item WITHOUT the consumer having
|
|
88
|
+
* to remember to wrap (D15: the SAFE state must be the default). An
|
|
89
|
+
* already-wrapped sink is returned as-is (no double-wrap). Used internally by the
|
|
90
|
+
* worker constructors.
|
|
91
|
+
*/
|
|
92
|
+
export function ensurePiiRedaction(sink) {
|
|
93
|
+
if (sink[PII_REDACTING])
|
|
94
|
+
return sink;
|
|
95
|
+
return withPiiRedaction(sink);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reference sink #1: accepts the dead item but logs it LOUDLY (error-level,
|
|
99
|
+
* with the failure cause + identity, never the raw payload — that's already
|
|
100
|
+
* redacted by `withPiiRedaction`). It does NOT persist. Useful as an explicit
|
|
101
|
+
* "I have NO durable DLQ yet but I refuse to silently drop" choice. NEVER
|
|
102
|
+
* auto-wired — the caller must construct + inject it.
|
|
103
|
+
*/
|
|
104
|
+
export class LoudNoopDeadLetterSink {
|
|
105
|
+
logger;
|
|
106
|
+
constructor(logger) {
|
|
107
|
+
this.logger = logger;
|
|
108
|
+
}
|
|
109
|
+
async deadLetter(item) {
|
|
110
|
+
this.logger.error("worker.dead_letter (no durable sink wired)", {
|
|
111
|
+
source_stream: item.sourceStream,
|
|
112
|
+
topic: item.topic,
|
|
113
|
+
event_id: item.eventId,
|
|
114
|
+
error_message: item.errorMessage,
|
|
115
|
+
retry_count: item.retryCount,
|
|
116
|
+
// payload is redacted by withPiiRedaction before reaching here.
|
|
117
|
+
payload: item.payload,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reference sink #2: persists the dead item into a CONSUMER-OWNED quarantine
|
|
123
|
+
* table the caller opts into (passing its OWN transaction/pool executor + table
|
|
124
|
+
* name). This is the SUBSCRIBER's own DLQ — NOT a source-side `outbox_dlq`
|
|
125
|
+
* (which D401 forbids). The caller controls the executor, so the write joins
|
|
126
|
+
* whatever transaction the caller chooses (or runs standalone on a pool conn).
|
|
127
|
+
*/
|
|
128
|
+
export class TableDeadLetterSink {
|
|
129
|
+
table;
|
|
130
|
+
insertSql;
|
|
131
|
+
exec;
|
|
132
|
+
constructor(opts) {
|
|
133
|
+
this.exec = opts.executor;
|
|
134
|
+
this.table = opts.table ?? "worker_dead_letter";
|
|
135
|
+
const t = quoteIdent(this.table);
|
|
136
|
+
this.insertSql = `INSERT INTO ${t}
|
|
137
|
+
(source_stream, topic, event_id, payload, error_message, retry_count, dead_lettered_at)
|
|
138
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`;
|
|
139
|
+
}
|
|
140
|
+
/** Idempotent DDL for the consumer-owned quarantine table. */
|
|
141
|
+
static tableDDL(table = "worker_dead_letter") {
|
|
142
|
+
const t = quoteIdent(table);
|
|
143
|
+
return `CREATE TABLE IF NOT EXISTS ${t} (
|
|
144
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
145
|
+
source_stream TEXT NOT NULL,
|
|
146
|
+
topic TEXT NOT NULL,
|
|
147
|
+
event_id TEXT NOT NULL,
|
|
148
|
+
payload JSONB NOT NULL,
|
|
149
|
+
error_message TEXT NOT NULL,
|
|
150
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
151
|
+
dead_lettered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
152
|
+
)`;
|
|
153
|
+
}
|
|
154
|
+
async deadLetter(item) {
|
|
155
|
+
await this.exec.query(this.insertSql, [
|
|
156
|
+
item.sourceStream,
|
|
157
|
+
item.topic,
|
|
158
|
+
item.eventId,
|
|
159
|
+
JSON.stringify(item.payload ?? {}),
|
|
160
|
+
item.errorMessage,
|
|
161
|
+
item.retryCount,
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=dead-letter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dead-letter.js","sourceRoot":"","sources":["../../src/worker/dead-letter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SqlExecutor } from "./tx.js";
|
|
2
|
+
/** The canonical un-prefixed dedupe table (per-service DB). */
|
|
3
|
+
export declare const DEFAULT_CONSUMED_EVENTS_TABLE = "consumed_events";
|
|
4
|
+
/** Default retention horizon for the sweep — 30d per 09-replication § 6. */
|
|
5
|
+
export declare const DEFAULT_RETENTION_DAYS = 30;
|
|
6
|
+
/** Idempotent DDL for the composite-PK dedupe table (D198). */
|
|
7
|
+
export declare function consumedEventsTableDDL(table?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Dedupe over `consumed_events (event_id, topic)` (D198 composite PK). One
|
|
10
|
+
* instance serves a whole service DB (the table is per-service). All writes
|
|
11
|
+
* join the caller's apply transaction via the injected `tx`.
|
|
12
|
+
*/
|
|
13
|
+
export declare class DedupeStore {
|
|
14
|
+
readonly table: string;
|
|
15
|
+
private readonly selectSql;
|
|
16
|
+
private readonly insertSql;
|
|
17
|
+
private readonly sweepSql;
|
|
18
|
+
constructor(opts?: {
|
|
19
|
+
table?: string;
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Has `(event_id, topic)` already been consumed? Call at step [1] of consume
|
|
23
|
+
* to skip an already-applied event before any work. Pass the apply `tx` (so
|
|
24
|
+
* the probe sees the tx's own uncommitted inserts) or a pooled connection for
|
|
25
|
+
* a cheap pre-tx probe.
|
|
26
|
+
*/
|
|
27
|
+
seen(eventId: string, topic: string, tx: SqlExecutor): Promise<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* Record `(event_id, topic)` as consumed INSIDE the caller's apply tx — the
|
|
30
|
+
* SAME `tx` the projection upsert + `checkpoint.advance(...)` run on, so the
|
|
31
|
+
* dedupe row commits together-or-none with them.
|
|
32
|
+
*
|
|
33
|
+
* @returns `true` if this call inserted the row (first time consumed), `false`
|
|
34
|
+
* if it was already present (ON CONFLICT DO NOTHING) — lets the
|
|
35
|
+
* caller detect a concurrent double-consume.
|
|
36
|
+
*/
|
|
37
|
+
record(eventId: string, topic: string, tx: SqlExecutor): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Retention sweep — DELETE dedupe rows older than `retentionDays` (default
|
|
40
|
+
* 30d per 09-replication § 6). Run on a SweeperWorker tick. Returns the number
|
|
41
|
+
* of rows removed (surface as `nodii.worker.swept_total`). `tx` may be a
|
|
42
|
+
* pooled connection — the sweep is independent of any apply transaction.
|
|
43
|
+
*/
|
|
44
|
+
sweep(tx: SqlExecutor, retentionDays?: number): Promise<number>;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=dedupe-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedupe-store.d.ts","sourceRoot":"","sources":["../../src/worker/dedupe-store.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,WAAW,EAAU,MAAM,SAAS,CAAC;AAYnD,+DAA+D;AAC/D,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAE/D,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAEzC,+DAA+D;AAC/D,wBAAgB,sBAAsB,CACpC,KAAK,SAAgC,GACpC,MAAM,CAQR;AAMD;;;;GAIG;AACH,qBAAa,WAAW;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO;IAczC;;;;;OAKG;IACG,IAAI,CACR,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,OAAO,CAAC;IAKnB;;;;;;;;OAQG;IACG,MAAM,CACV,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,OAAO,CAAC;IAKnB;;;;;OAKG;IACG,KAAK,CACT,EAAE,EAAE,WAAW,EACf,aAAa,GAAE,MAA+B,GAC7C,OAAO,CAAC,MAAM,CAAC;CASnB"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// D402 — DedupeStore: the at-most-once dedupe primitive over the
|
|
2
|
+
// `consumed_events (event_id, topic)` composite-PK table.
|
|
3
|
+
//
|
|
4
|
+
// D402 (LOCKED):
|
|
5
|
+
// "DedupeStore — `consumed_events (event_id, topic)` composite-PK dedupe +
|
|
6
|
+
// retention sweep (default 30d per 09-replication-doctrine § 6)."
|
|
7
|
+
//
|
|
8
|
+
// D198 (LOCKED 2026-06-03) pins the COMPOSITE pk: the same event_id can
|
|
9
|
+
// legitimately recur across topics, so an event_id-only dedupe would wrongly
|
|
10
|
+
// short-circuit the second topic. Keying on `(event_id, topic)` is the
|
|
11
|
+
// cross-topic-correctness fix (see ts/replica-consumer/src/sql-builders.ts
|
|
12
|
+
// DEDUPE_SELECT_SQL / CONSUMED_EVENTS_INSERT_SQL — generalized here, NOT
|
|
13
|
+
// duplicated; same table, same column shape).
|
|
14
|
+
//
|
|
15
|
+
// The `consumed_events` table is intentionally UN-prefixed: each adopting
|
|
16
|
+
// service owns its own database, so the table is already per-service (operator-
|
|
17
|
+
// confirmed; see memory feedback_consumed_events_table).
|
|
18
|
+
//
|
|
19
|
+
// ── Atomic-in-apply-tx (the load-bearing property) ───────────────────────────
|
|
20
|
+
// `record(...)` runs on the CALLER's `tx` handle, the SAME one the projection
|
|
21
|
+
// upsert + `checkpoint.advance(...)` run on. The dedupe row is therefore
|
|
22
|
+
// committed together-or-none with the projection + the cursor: if apply fails
|
|
23
|
+
// after `record(...)`, the rollback removes the dedupe row too, so the event
|
|
24
|
+
// is NOT falsely marked consumed (a retry re-processes it). And because the
|
|
25
|
+
// cursor advance is in the same tx, a committed dedupe row always has a
|
|
26
|
+
// committed cursor that covers it.
|
|
27
|
+
//
|
|
28
|
+
// `seen(...)` is the read-side probe at step [1] of the consume sequence:
|
|
29
|
+
// call it (on the tx, or a pooled conn pre-tx) to skip an already-applied
|
|
30
|
+
// (event_id, topic) before doing any work.
|
|
31
|
+
/** Strict SQL identifier whitelist (mirrors replica-consumer's quoteIdent). */
|
|
32
|
+
const IDENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
33
|
+
function quoteIdent(name) {
|
|
34
|
+
if (!IDENT_PATTERN.test(name)) {
|
|
35
|
+
throw new Error(`invalid SQL identifier: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
return `"${name}"`;
|
|
38
|
+
}
|
|
39
|
+
/** The canonical un-prefixed dedupe table (per-service DB). */
|
|
40
|
+
export const DEFAULT_CONSUMED_EVENTS_TABLE = "consumed_events";
|
|
41
|
+
/** Default retention horizon for the sweep — 30d per 09-replication § 6. */
|
|
42
|
+
export const DEFAULT_RETENTION_DAYS = 30;
|
|
43
|
+
/** Idempotent DDL for the composite-PK dedupe table (D198). */
|
|
44
|
+
export function consumedEventsTableDDL(table = DEFAULT_CONSUMED_EVENTS_TABLE) {
|
|
45
|
+
const t = quoteIdent(table);
|
|
46
|
+
return `CREATE TABLE IF NOT EXISTS ${t} (
|
|
47
|
+
event_id TEXT NOT NULL,
|
|
48
|
+
topic TEXT NOT NULL,
|
|
49
|
+
consumed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
50
|
+
PRIMARY KEY (event_id, topic)
|
|
51
|
+
)`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Dedupe over `consumed_events (event_id, topic)` (D198 composite PK). One
|
|
55
|
+
* instance serves a whole service DB (the table is per-service). All writes
|
|
56
|
+
* join the caller's apply transaction via the injected `tx`.
|
|
57
|
+
*/
|
|
58
|
+
export class DedupeStore {
|
|
59
|
+
table;
|
|
60
|
+
selectSql;
|
|
61
|
+
insertSql;
|
|
62
|
+
sweepSql;
|
|
63
|
+
constructor(opts = {}) {
|
|
64
|
+
this.table = opts.table ?? DEFAULT_CONSUMED_EVENTS_TABLE;
|
|
65
|
+
const t = quoteIdent(this.table);
|
|
66
|
+
this.selectSql = `SELECT 1 AS one FROM ${t} WHERE event_id = $1 AND topic = $2 LIMIT 1`;
|
|
67
|
+
// ON CONFLICT DO NOTHING: a concurrent / replayed insert is a benign no-op
|
|
68
|
+
// (the row already proves consumption); rowCount distinguishes first-insert
|
|
69
|
+
// (1) from already-seen (0).
|
|
70
|
+
this.insertSql = `INSERT INTO ${t} (event_id, topic, consumed_at) VALUES ($1, $2, now())
|
|
71
|
+
ON CONFLICT (event_id, topic) DO NOTHING`;
|
|
72
|
+
// Retention sweep — parameterized day horizon, cast to an interval. Mirrors
|
|
73
|
+
// replica-consumer's SWEEP_CONSUMED_EVENTS_SQL.
|
|
74
|
+
this.sweepSql = `DELETE FROM ${t} WHERE consumed_at < now() - ($1::int || ' days')::interval`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Has `(event_id, topic)` already been consumed? Call at step [1] of consume
|
|
78
|
+
* to skip an already-applied event before any work. Pass the apply `tx` (so
|
|
79
|
+
* the probe sees the tx's own uncommitted inserts) or a pooled connection for
|
|
80
|
+
* a cheap pre-tx probe.
|
|
81
|
+
*/
|
|
82
|
+
async seen(eventId, topic, tx) {
|
|
83
|
+
const res = await tx.query(this.selectSql, [eventId, topic]);
|
|
84
|
+
return res.rows.length > 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record `(event_id, topic)` as consumed INSIDE the caller's apply tx — the
|
|
88
|
+
* SAME `tx` the projection upsert + `checkpoint.advance(...)` run on, so the
|
|
89
|
+
* dedupe row commits together-or-none with them.
|
|
90
|
+
*
|
|
91
|
+
* @returns `true` if this call inserted the row (first time consumed), `false`
|
|
92
|
+
* if it was already present (ON CONFLICT DO NOTHING) — lets the
|
|
93
|
+
* caller detect a concurrent double-consume.
|
|
94
|
+
*/
|
|
95
|
+
async record(eventId, topic, tx) {
|
|
96
|
+
const res = await tx.query(this.insertSql, [eventId, topic]);
|
|
97
|
+
return res.rowCount > 0;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Retention sweep — DELETE dedupe rows older than `retentionDays` (default
|
|
101
|
+
* 30d per 09-replication § 6). Run on a SweeperWorker tick. Returns the number
|
|
102
|
+
* of rows removed (surface as `nodii.worker.swept_total`). `tx` may be a
|
|
103
|
+
* pooled connection — the sweep is independent of any apply transaction.
|
|
104
|
+
*/
|
|
105
|
+
async sweep(tx, retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
106
|
+
if (!Number.isInteger(retentionDays) || retentionDays < 0) {
|
|
107
|
+
throw new Error(`retentionDays must be a non-negative integer, got ${retentionDays}`);
|
|
108
|
+
}
|
|
109
|
+
const res = await tx.query(this.sweepSql, [retentionDays]);
|
|
110
|
+
return res.rowCount;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=dedupe-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedupe-store.js","sourceRoot":"","sources":["../../src/worker/dedupe-store.ts"],"names":[],"mappings":"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"}
|