@helipod/triggers 0.1.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.
- package/dist/index.d.ts +225 -0
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Driver, LogChange, ComponentDefinition } from '@helipod/component';
|
|
2
|
+
import * as _helipod_values from '@helipod/values';
|
|
3
|
+
|
|
4
|
+
interface TriggerConfig {
|
|
5
|
+
/** App fn path — an internal (`_`-prefixed) mutation or action; see `./boot.ts`'s `validateHandlers`. */
|
|
6
|
+
handler: string;
|
|
7
|
+
/** Max changes per handler invocation. Default `DEFAULT_BATCH_SIZE` (64). */
|
|
8
|
+
batchSize?: number;
|
|
9
|
+
/** Start at log ts 0 (replay every existing revision) instead of the tip. Default `false`. */
|
|
10
|
+
fromStart?: boolean;
|
|
11
|
+
/** Circuit-breaker threshold: deliveries allowed per `BREAKER_WINDOW_MS`. Default `DEFAULT_MAX_DELIVERIES_PER_WINDOW` (1000). */
|
|
12
|
+
maxDeliveriesPerWindow?: number;
|
|
13
|
+
}
|
|
14
|
+
type TriggersOpts = Record<string, TriggerConfig>;
|
|
15
|
+
/** Design spec D2 default. */
|
|
16
|
+
declare const DEFAULT_BATCH_SIZE = 64;
|
|
17
|
+
/** Design spec D2: "~1MB serialized — full docs travel in the args; cut the batch early when exceeded." Approximated via `JSON.stringify(...).length` (UTF-16 code units, not exact bytes) — a ballpark budget, not an exact enforcement. */
|
|
18
|
+
declare const BYTE_BUDGET = 1000000;
|
|
19
|
+
/** Design spec D2 default (the circuit breaker). */
|
|
20
|
+
declare const DEFAULT_MAX_DELIVERIES_PER_WINDOW = 1000;
|
|
21
|
+
/** Design spec D2: the breaker's fixed window size. */
|
|
22
|
+
declare const BREAKER_WINDOW_MS = 10000;
|
|
23
|
+
/**
|
|
24
|
+
* A `triggersDriver()` also exposes:
|
|
25
|
+
* - `__tick(name?)`: a deterministic test seam for one pass of trigger `name` (or every
|
|
26
|
+
* configured trigger, awaited together, if omitted) — no real timers. Calling it with no
|
|
27
|
+
* `name` is also the deterministic stand-in for "the periodic beat fired" (`BEAT_MS`,
|
|
28
|
+
* `armBeat` below) — both do exactly `wakeAll()`, so a test never needs to wait out the real
|
|
29
|
+
* 30s timer to prove a resumed trigger gets picked back up.
|
|
30
|
+
* - `__wake(name?)`: the same fire-and-forget signal `DriverContext.onCommit`/a retry timer use
|
|
31
|
+
* internally, exposed so a test can simulate one landing at a precise moment.
|
|
32
|
+
*/
|
|
33
|
+
interface TriggersDriver extends Driver {
|
|
34
|
+
__tick: (name?: string) => Promise<void>;
|
|
35
|
+
__wake: (name?: string) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `@helipod/triggers`' driver — one independent event loop PER configured trigger (design spec
|
|
39
|
+
* D2), not a single shared loop: each trigger name gets its own `running`/`pendingWake`/`inFlight`
|
|
40
|
+
* coalescing state (mirroring `@helipod/scheduler`'s `schedulerDriver` shape, but keyed by
|
|
41
|
+
* name), so a slow handler on trigger A can never head-of-line-block trigger B's independent
|
|
42
|
+
* backlog — they're just separate promise chains woken by the same commit fan-out.
|
|
43
|
+
*
|
|
44
|
+
* Two wake sources per trigger, no fixed-interval polling:
|
|
45
|
+
* - **Reactive**: `DriverContext.onCommit` fires `wake(name)` for every trigger `name` whose
|
|
46
|
+
* OWN watched table appears in the commit's `tables` — and for EVERY trigger when
|
|
47
|
+
* `"triggers/cursors"` itself is touched (an external `triggers:resume` call, which any
|
|
48
|
+
* trigger's paused state could be waiting on).
|
|
49
|
+
* - **Timer**: a failed delivery arms a single retry timer for that trigger, per
|
|
50
|
+
* `computeBackoff` (`@helipod/scheduler`) — see `_recordFailure`'s doc comment (`./modules.ts`)
|
|
51
|
+
* for why the delay itself is in-memory, not persisted.
|
|
52
|
+
*
|
|
53
|
+
* Each trigger's attempt (`runOnePass`) loops internally — draining quiet-table progress and
|
|
54
|
+
* full matched batches alike — until `cursorTs` reaches the bound captured at the START of that
|
|
55
|
+
* attempt (`targetBound`), a delivery fails, or the breaker/max-failures pauses it; `runPass`
|
|
56
|
+
* wraps that in an outer `do..while(pendingWake)` (scheduler's exact shape) so a wake landing
|
|
57
|
+
* mid-drain gets one more FULL attempt (a fresh `targetBound` peek included) rather than being
|
|
58
|
+
* silently dropped. This is the precise, non-spinning inference the T1 handoff calls for:
|
|
59
|
+
* `readLog` exposes no explicit "did the scan hit its limit" flag, so rather than guess from
|
|
60
|
+
* `changes.length` alone, each attempt just keeps asking "is there more, up to where I started?"
|
|
61
|
+
* (bounded, since that target can never move against a trigger's own writes — see `runPass`'s
|
|
62
|
+
* doc comment) until the answer is unambiguously "no."
|
|
63
|
+
*/
|
|
64
|
+
declare function triggersDriver(opts: TriggersOpts): TriggersDriver;
|
|
65
|
+
/**
|
|
66
|
+
* Cuts `changes` (already fully scanned by `readLog`, ascending ts) to fit `BYTE_BUDGET`, WITHOUT
|
|
67
|
+
* ever splitting a ts group — a single commit can touch multiple documents in the same watched
|
|
68
|
+
* table, producing multiple `LogChange`s at the same `ts`; delivering some of a group while
|
|
69
|
+
* excluding the rest and then advancing past that `ts` would silently and permanently skip the
|
|
70
|
+
* excluded ones (the same invariant `readLog`'s own `limit` handling enforces internally — see
|
|
71
|
+
* `runtime.ts`'s `readLog`).
|
|
72
|
+
*
|
|
73
|
+
* Degenerate case: the FIRST ts group alone already exceeds the budget (one huge document, or
|
|
74
|
+
* many documents committed together). Mirrors `readLog`'s own same-ts-group handling: deliver it
|
|
75
|
+
* whole, unbounded — a batch of size 1 that's still too big can't be shrunk further, and
|
|
76
|
+
* indefinitely refusing to deliver it would stall the trigger forever.
|
|
77
|
+
*
|
|
78
|
+
* Returns `advanceTs`: `maxScannedTs` when nothing was cut (the common case — the whole scanned
|
|
79
|
+
* window fit), or the ts of the last INCLUDED change when a cut occurred (never `maxScannedTs`,
|
|
80
|
+
* which would skip the excluded, not-yet-delivered tail).
|
|
81
|
+
*/
|
|
82
|
+
declare function cutToByteBudget(changes: readonly LogChange[], maxScannedTs: number): {
|
|
83
|
+
batch: LogChange[];
|
|
84
|
+
advanceTs: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The `@helipod/triggers` component schema (namespaced `triggers/*` when composed) — design
|
|
89
|
+
* spec D2. One `cursors` row per configured trigger, keyed by the WATCHED TABLE NAME (`name`) —
|
|
90
|
+
* see `../docs`/the design spec for why a table rename orphans the cursor rather than migrating
|
|
91
|
+
* it.
|
|
92
|
+
*
|
|
93
|
+
* - `cursorTs`: the log coordinate this trigger has fully delivered through — see
|
|
94
|
+
* `DriverContext.readLog`'s doc comment (`@helipod/component`) for why this is ALWAYS
|
|
95
|
+
* `maxScannedTs`, never a delivered change's own `ts`.
|
|
96
|
+
* - `state`: `"running"` (the driver dispatches this trigger on every relevant wake) or
|
|
97
|
+
* `"paused"` (max-consecutive-failures or the circuit breaker tripped; the driver skips it
|
|
98
|
+
* entirely until `triggers:resume` flips it back).
|
|
99
|
+
* - `failureCount`: consecutive handler failures since the last successful delivery (or since
|
|
100
|
+
* the trigger was created/resumed) — reset to 0 by `_advanceCursor` on every successful
|
|
101
|
+
* delivery/quiet-table advance. PERSISTED (restart-safe), unlike the backoff retry timer
|
|
102
|
+
* itself, which is in-memory only (see `driver.ts`'s module doc comment).
|
|
103
|
+
* - `lastError` / `pausedReason`: operator-visible diagnostics; both cleared by `resume`.
|
|
104
|
+
*/
|
|
105
|
+
declare const triggersSchema: _helipod_values.SchemaDefinition<{
|
|
106
|
+
cursors: _helipod_values.TableDefinition<{
|
|
107
|
+
name: {
|
|
108
|
+
readonly kind: "string";
|
|
109
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
110
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
111
|
+
readonly _output: string;
|
|
112
|
+
readonly isOptional: "required";
|
|
113
|
+
};
|
|
114
|
+
cursorTs: {
|
|
115
|
+
readonly kind: "number";
|
|
116
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
117
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
118
|
+
readonly _output: number;
|
|
119
|
+
readonly isOptional: "required";
|
|
120
|
+
};
|
|
121
|
+
state: {
|
|
122
|
+
readonly kind: "union";
|
|
123
|
+
readonly members: [{
|
|
124
|
+
readonly value: "running";
|
|
125
|
+
readonly kind: "literal";
|
|
126
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
127
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
128
|
+
readonly _output: "running";
|
|
129
|
+
readonly isOptional: "required";
|
|
130
|
+
}, {
|
|
131
|
+
readonly value: "paused";
|
|
132
|
+
readonly kind: "literal";
|
|
133
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
134
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
135
|
+
readonly _output: "paused";
|
|
136
|
+
readonly isOptional: "required";
|
|
137
|
+
}];
|
|
138
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
139
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
140
|
+
readonly _output: "running" | "paused";
|
|
141
|
+
readonly isOptional: "required";
|
|
142
|
+
};
|
|
143
|
+
failureCount: {
|
|
144
|
+
readonly kind: "number";
|
|
145
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
146
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
147
|
+
readonly _output: number;
|
|
148
|
+
readonly isOptional: "required";
|
|
149
|
+
};
|
|
150
|
+
lastError: {
|
|
151
|
+
readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
|
|
152
|
+
readonly kind: "optional";
|
|
153
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
154
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
155
|
+
readonly _output: string | undefined;
|
|
156
|
+
readonly isOptional: "optional";
|
|
157
|
+
};
|
|
158
|
+
pausedReason: {
|
|
159
|
+
readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
|
|
160
|
+
readonly kind: "optional";
|
|
161
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
162
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
163
|
+
readonly _output: string | undefined;
|
|
164
|
+
readonly isOptional: "optional";
|
|
165
|
+
};
|
|
166
|
+
}>;
|
|
167
|
+
}>;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Internal modules for `@helipod/triggers` — registered on `defineTriggers()`'s `modules` map
|
|
171
|
+
* (reachable as `triggers:_initCursor` / `triggers:_getCursor` / etc.), consumed by the driver
|
|
172
|
+
* loop (`./driver.ts`) via `DriverContext.runFunction`, which dispatches privileged
|
|
173
|
+
* (`runtime-embedded/src/runtime.ts`'s `driverCtx.runFunction` sets `privileged: true`) —
|
|
174
|
+
* privileged calls bypass namespace prefixing, so these modules use the fully-qualified table
|
|
175
|
+
* name `"triggers/cursors"` (mirrors `@helipod/scheduler`'s `./modules.ts` module doc comment).
|
|
176
|
+
*
|
|
177
|
+
* `resume` is deliberately NOT `_`-prefixed (unlike every other module here): it's meant to be an
|
|
178
|
+
* ordinary callable mutation an operator/the dashboard's function runner invokes directly
|
|
179
|
+
* (`triggers:resume`, per the design spec) — see `defineTriggers`'s doc comment in `./index.ts`.
|
|
180
|
+
*/
|
|
181
|
+
/** After this many CONSECUTIVE handler failures, a trigger pauses itself (design spec D2). */
|
|
182
|
+
declare const MAX_CONSECUTIVE_FAILURES = 8;
|
|
183
|
+
interface CursorRow {
|
|
184
|
+
_id: string;
|
|
185
|
+
name: string;
|
|
186
|
+
cursorTs: number;
|
|
187
|
+
state: "running" | "paused";
|
|
188
|
+
failureCount: number;
|
|
189
|
+
lastError?: string;
|
|
190
|
+
pausedReason?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `defineTriggers(opts)` — the `@helipod/triggers` component: react to committed data changes
|
|
195
|
+
* server-side, durably, without a queue — a trigger is a durable cursor over the MVCC log (the
|
|
196
|
+
* design spec's core idea; see `docs/superpowers/specs/2025-10-16-onchange-triggers-design.md`).
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* // helipod.config.ts
|
|
200
|
+
* import { defineTriggers } from "@helipod/triggers";
|
|
201
|
+
* export default defineConfig({
|
|
202
|
+
* components: [
|
|
203
|
+
* defineTriggers({
|
|
204
|
+
* messages: { handler: "notifications:_onMessage" }, // an internalMutation/internalAction
|
|
205
|
+
* users: { handler: "audit:_onUserChange", fromStart: true },
|
|
206
|
+
* }),
|
|
207
|
+
* ],
|
|
208
|
+
* });
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* Each key is a WATCHED TABLE NAME; `handler` is an internal (`_`-prefixed) mutation or action
|
|
212
|
+
* receiving `{ changes: LogChange[] }` (`@helipod/component`'s `LogChange` — see its doc
|
|
213
|
+
* comment for `op`/`oldDoc`/`changeId` semantics). No `context`/`contextType` — unlike
|
|
214
|
+
* `@helipod/scheduler`, nothing calls a `ctx.triggers.*` facade from a mutation; the whole
|
|
215
|
+
* surface is this declarative config plus the `triggers:resume` mutation
|
|
216
|
+
* (`./modules.ts`) for un-pausing a tripped/failed trigger.
|
|
217
|
+
*
|
|
218
|
+
* Boot-time handler validation (unknown path / non-internal / wrong kind, all fail-fast) and
|
|
219
|
+
* cursor initialization (tip, or `fromStart: true`'s ts-0 replay) both happen inside the driver's
|
|
220
|
+
* `start()`, not a `ComponentDefinition.boot` step — see `./boot.ts`'s module doc comment for why
|
|
221
|
+
* `BootContext` structurally can't do either.
|
|
222
|
+
*/
|
|
223
|
+
declare function defineTriggers(opts: TriggersOpts): ComponentDefinition;
|
|
224
|
+
|
|
225
|
+
export { BREAKER_WINDOW_MS, BYTE_BUDGET, type CursorRow, DEFAULT_BATCH_SIZE, DEFAULT_MAX_DELIVERIES_PER_WINDOW, MAX_CONSECUTIVE_FAILURES, type TriggerConfig, type TriggersDriver, type TriggersOpts, cutToByteBudget, defineTriggers, triggersDriver, triggersSchema };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { defineComponent } from "@helipod/component";
|
|
3
|
+
|
|
4
|
+
// src/schema.ts
|
|
5
|
+
import { defineSchema, defineTable, v } from "@helipod/values";
|
|
6
|
+
var triggersSchema = defineSchema({
|
|
7
|
+
cursors: defineTable({
|
|
8
|
+
name: v.string(),
|
|
9
|
+
cursorTs: v.number(),
|
|
10
|
+
state: v.union(v.literal("running"), v.literal("paused")),
|
|
11
|
+
failureCount: v.number(),
|
|
12
|
+
lastError: v.optional(v.string()),
|
|
13
|
+
pausedReason: v.optional(v.string())
|
|
14
|
+
}).index("by_name", ["name"])
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// src/modules.ts
|
|
18
|
+
import { query, mutation } from "@helipod/executor";
|
|
19
|
+
import { computeBackoff } from "@helipod/scheduler";
|
|
20
|
+
var MAX_CONSECUTIVE_FAILURES = 8;
|
|
21
|
+
function compact(obj) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v2] of Object.entries(obj)) if (v2 !== void 0) out[k] = v2;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
var _getCursor = query(async (ctx, args) => {
|
|
27
|
+
const rows = await ctx.db.query("triggers/cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
28
|
+
return rows[0] ?? null;
|
|
29
|
+
});
|
|
30
|
+
var _status = query(async (ctx) => {
|
|
31
|
+
const rows = await ctx.db.query("triggers/cursors", "by_creation").collect();
|
|
32
|
+
return rows;
|
|
33
|
+
});
|
|
34
|
+
var _initCursor = mutation(async (ctx, args) => {
|
|
35
|
+
const existing = await ctx.db.query("triggers/cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
36
|
+
if (existing[0]) return existing[0];
|
|
37
|
+
const id = await ctx.db.insert("triggers/cursors", {
|
|
38
|
+
name: args.name,
|
|
39
|
+
cursorTs: args.cursorTs,
|
|
40
|
+
state: "running",
|
|
41
|
+
failureCount: 0
|
|
42
|
+
});
|
|
43
|
+
return { _id: id, name: args.name, cursorTs: args.cursorTs, state: "running", failureCount: 0 };
|
|
44
|
+
});
|
|
45
|
+
var _advanceCursor = mutation(
|
|
46
|
+
async (ctx, args) => {
|
|
47
|
+
const rows = await ctx.db.query("triggers/cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
48
|
+
const row = rows[0];
|
|
49
|
+
if (!row) return null;
|
|
50
|
+
if (row.cursorTs !== args.expectedPrev) return null;
|
|
51
|
+
await ctx.db.replace(
|
|
52
|
+
row._id,
|
|
53
|
+
compact({ ...row, cursorTs: args.newCursorTs, failureCount: 0, lastError: void 0 })
|
|
54
|
+
);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
var _recordFailure = mutation(
|
|
59
|
+
async (ctx, args) => {
|
|
60
|
+
const rows = await ctx.db.query("triggers/cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
61
|
+
const row = rows[0];
|
|
62
|
+
if (!row) return { paused: false, retryDelayMs: 0 };
|
|
63
|
+
const failureCount = row.failureCount + 1;
|
|
64
|
+
if (failureCount >= MAX_CONSECUTIVE_FAILURES) {
|
|
65
|
+
await ctx.db.replace(
|
|
66
|
+
row._id,
|
|
67
|
+
compact({ ...row, failureCount, lastError: args.error, state: "paused", pausedReason: "max-failures" })
|
|
68
|
+
);
|
|
69
|
+
return { paused: true, retryDelayMs: 0 };
|
|
70
|
+
}
|
|
71
|
+
await ctx.db.replace(row._id, compact({ ...row, failureCount, lastError: args.error }));
|
|
72
|
+
return { paused: false, retryDelayMs: computeBackoff(failureCount, ctx.random) };
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
var _pause = mutation(async (ctx, args) => {
|
|
76
|
+
const rows = await ctx.db.query("triggers/cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
77
|
+
const row = rows[0];
|
|
78
|
+
if (!row || row.state === "paused") return null;
|
|
79
|
+
await ctx.db.replace(row._id, compact({ ...row, state: "paused", pausedReason: args.reason }));
|
|
80
|
+
return null;
|
|
81
|
+
});
|
|
82
|
+
var resume = mutation(async (ctx, args) => {
|
|
83
|
+
const rows = await ctx.db.query("cursors", "by_name").eq("name", args.name).take(1).collect();
|
|
84
|
+
const row = rows[0];
|
|
85
|
+
if (!row) return null;
|
|
86
|
+
await ctx.db.replace(
|
|
87
|
+
row._id,
|
|
88
|
+
compact({ ...row, state: "running", failureCount: 0, lastError: void 0, pausedReason: void 0 })
|
|
89
|
+
);
|
|
90
|
+
return null;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// src/boot.ts
|
|
94
|
+
function isInternalPath(path) {
|
|
95
|
+
return path.split(":").some((seg) => seg.startsWith("_"));
|
|
96
|
+
}
|
|
97
|
+
function validateHandlers(ctx, opts) {
|
|
98
|
+
if (!ctx.functionKind) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"@helipod/triggers: the runtime's DriverContext does not provide functionKind \u2014 handler validation cannot run. This means the composed runtime is older than @helipod/component's triggers support; upgrade @helipod/runtime-embedded."
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
for (const [table, cfg] of Object.entries(opts)) {
|
|
104
|
+
const kind = ctx.functionKind(cfg.handler);
|
|
105
|
+
if (kind === void 0) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`@helipod/triggers: trigger "${table}" references handler "${cfg.handler}", which is not a registered function. Check the path matches an exported mutation or action (e.g. "notifications:_onMessage").`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!isInternalPath(cfg.handler)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`@helipod/triggers: trigger "${table}"'s handler "${cfg.handler}" must be an internal function \u2014 a module or function name segment prefixed with "_" (e.g. "notifications:_onMessage"). Trigger handlers are driven only by the trigger loop and must not be directly client-callable.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (kind !== "mutation" && kind !== "action") {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`@helipod/triggers: trigger "${table}"'s handler "${cfg.handler}" is a ${kind}, not a mutation or action. Trigger handlers must be an internal mutation or action.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function ensureCursor(ctx, name, fromStart, tipIfNew) {
|
|
123
|
+
const existing = await ctx.runFunction("triggers:_getCursor", { name });
|
|
124
|
+
if (existing) return existing;
|
|
125
|
+
const cursorTs = fromStart ? 0 : tipIfNew;
|
|
126
|
+
const created = await ctx.runFunction("triggers:_initCursor", { name, cursorTs });
|
|
127
|
+
return created;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/driver.ts
|
|
131
|
+
var DEFAULT_BATCH_SIZE = 64;
|
|
132
|
+
var BYTE_BUDGET = 1e6;
|
|
133
|
+
var DEFAULT_MAX_DELIVERIES_PER_WINDOW = 1e3;
|
|
134
|
+
var BREAKER_WINDOW_MS = 1e4;
|
|
135
|
+
var BEAT_MS = 3e4;
|
|
136
|
+
function triggersDriver(opts) {
|
|
137
|
+
let ctx;
|
|
138
|
+
let stopped = false;
|
|
139
|
+
const names = Object.keys(opts);
|
|
140
|
+
const running = /* @__PURE__ */ new Map();
|
|
141
|
+
const pendingWake = /* @__PURE__ */ new Map();
|
|
142
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
143
|
+
const retryTimers = /* @__PURE__ */ new Map();
|
|
144
|
+
const breakerWindows = /* @__PURE__ */ new Map();
|
|
145
|
+
const nextAttemptAt = /* @__PURE__ */ new Map();
|
|
146
|
+
function wakeAll() {
|
|
147
|
+
for (const name of names) wake(name);
|
|
148
|
+
}
|
|
149
|
+
function wake(name) {
|
|
150
|
+
if (stopped) return;
|
|
151
|
+
iterate(name).catch((e) => {
|
|
152
|
+
console.error(`[triggers] driver iteration for "${name}" failed:`, e);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function iterate(name) {
|
|
156
|
+
if (running.get(name)) {
|
|
157
|
+
pendingWake.set(name, true);
|
|
158
|
+
return inFlight.get(name) ?? Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
running.set(name, true);
|
|
161
|
+
const pass = runPass(name).finally(() => {
|
|
162
|
+
running.set(name, false);
|
|
163
|
+
inFlight.delete(name);
|
|
164
|
+
if (pendingWake.get(name)) {
|
|
165
|
+
pendingWake.set(name, false);
|
|
166
|
+
void wake(name);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
inFlight.set(name, pass);
|
|
170
|
+
return pass;
|
|
171
|
+
}
|
|
172
|
+
async function runPass(name) {
|
|
173
|
+
let iterations = 0;
|
|
174
|
+
do {
|
|
175
|
+
pendingWake.set(name, false);
|
|
176
|
+
await runOnePass(name);
|
|
177
|
+
if (++iterations > 1e3) {
|
|
178
|
+
console.error(`[triggers] "${name}": runPass exceeded 1000 iterations \u2014 bailing (possible bug).`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
} while (pendingWake.get(name));
|
|
182
|
+
}
|
|
183
|
+
async function runOnePass(name) {
|
|
184
|
+
const cfg = opts[name];
|
|
185
|
+
const waitUntil = nextAttemptAt.get(name);
|
|
186
|
+
if (waitUntil !== void 0 && ctx.now() < waitUntil) return;
|
|
187
|
+
const { maxScannedTs: targetBound } = await ctx.readLog({ afterTs: 0, tables: [], limit: 0 });
|
|
188
|
+
const cursor = await ensureCursor(ctx, name, cfg.fromStart === true, targetBound);
|
|
189
|
+
let cursorTs = cursor.cursorTs;
|
|
190
|
+
if (cursor.state !== "running") return;
|
|
191
|
+
const batchSize = cfg.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
192
|
+
const maxPerWindow = cfg.maxDeliveriesPerWindow ?? DEFAULT_MAX_DELIVERIES_PER_WINDOW;
|
|
193
|
+
while (cursorTs < targetBound) {
|
|
194
|
+
const { changes, maxScannedTs } = await ctx.readLog({ afterTs: cursorTs, tables: [name], limit: batchSize });
|
|
195
|
+
if (changes.length === 0) {
|
|
196
|
+
if (maxScannedTs <= cursorTs) break;
|
|
197
|
+
await ctx.runFunction("triggers:_advanceCursor", { name, newCursorTs: maxScannedTs, expectedPrev: cursorTs });
|
|
198
|
+
cursorTs = maxScannedTs;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const { batch, advanceTs } = cutToByteBudget(changes, maxScannedTs);
|
|
202
|
+
if (recordDeliveryAndCheckBreaker(name, ctx.now(), maxPerWindow)) {
|
|
203
|
+
await ctx.runFunction("triggers:_pause", { name, reason: "circuit-breaker" });
|
|
204
|
+
console.error(
|
|
205
|
+
`[triggers] "${name}" paused: circuit breaker tripped (> ${maxPerWindow} deliveries within ${BREAKER_WINDOW_MS}ms) \u2014 a self-recursive or runaway handler, most likely.`
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await ctx.runFunction(cfg.handler, { changes: batch });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
const result = await ctx.runFunction("triggers:_recordFailure", { name, error: String(e) });
|
|
213
|
+
if (result.paused) {
|
|
214
|
+
console.error(`[triggers] "${name}" paused after ${MAX_FAILURES_LOG_HINT} consecutive failures: ${String(e)}`);
|
|
215
|
+
nextAttemptAt.delete(name);
|
|
216
|
+
} else {
|
|
217
|
+
const retryAt = ctx.now() + result.retryDelayMs;
|
|
218
|
+
nextAttemptAt.set(name, retryAt);
|
|
219
|
+
armRetryTimer(name, retryAt);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await ctx.runFunction("triggers:_advanceCursor", { name, newCursorTs: advanceTs, expectedPrev: cursorTs });
|
|
224
|
+
cursorTs = advanceTs;
|
|
225
|
+
nextAttemptAt.delete(name);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function armRetryTimer(name, atMs) {
|
|
229
|
+
const existing = retryTimers.get(name);
|
|
230
|
+
if (existing !== void 0) ctx.clearTimer(existing);
|
|
231
|
+
const handle = ctx.setTimer(atMs, () => wake(name));
|
|
232
|
+
retryTimers.set(name, handle);
|
|
233
|
+
}
|
|
234
|
+
function recordDeliveryAndCheckBreaker(name, now, maxPerWindow) {
|
|
235
|
+
let w = breakerWindows.get(name);
|
|
236
|
+
if (!w || now - w.windowStart >= BREAKER_WINDOW_MS) {
|
|
237
|
+
w = { windowStart: now, count: 0 };
|
|
238
|
+
breakerWindows.set(name, w);
|
|
239
|
+
}
|
|
240
|
+
w.count++;
|
|
241
|
+
return w.count > maxPerWindow;
|
|
242
|
+
}
|
|
243
|
+
let unsubscribeCommit = null;
|
|
244
|
+
let beatTimer = null;
|
|
245
|
+
function armBeat() {
|
|
246
|
+
if (stopped) return;
|
|
247
|
+
if (beatTimer !== null) ctx.clearTimer(beatTimer);
|
|
248
|
+
beatTimer = ctx.setTimer(ctx.now() + ctx.backstopMs(BEAT_MS), () => {
|
|
249
|
+
wakeAll();
|
|
250
|
+
armBeat();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
name: "triggers",
|
|
255
|
+
start(c) {
|
|
256
|
+
ctx = c;
|
|
257
|
+
validateHandlers(ctx, opts);
|
|
258
|
+
unsubscribeCommit = ctx.onCommit((inv) => {
|
|
259
|
+
for (const name of names) if (inv.tables.includes(name)) wake(name);
|
|
260
|
+
});
|
|
261
|
+
wakeAll();
|
|
262
|
+
armBeat();
|
|
263
|
+
},
|
|
264
|
+
stop() {
|
|
265
|
+
stopped = true;
|
|
266
|
+
unsubscribeCommit?.();
|
|
267
|
+
unsubscribeCommit = null;
|
|
268
|
+
if (beatTimer !== null) {
|
|
269
|
+
ctx.clearTimer(beatTimer);
|
|
270
|
+
beatTimer = null;
|
|
271
|
+
}
|
|
272
|
+
for (const handle of retryTimers.values()) ctx.clearTimer(handle);
|
|
273
|
+
retryTimers.clear();
|
|
274
|
+
},
|
|
275
|
+
__tick: async (name) => {
|
|
276
|
+
if (name !== void 0) {
|
|
277
|
+
await iterate(name);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await Promise.all(names.map((n) => iterate(n)));
|
|
281
|
+
},
|
|
282
|
+
__wake: (name) => {
|
|
283
|
+
if (name !== void 0) wake(name);
|
|
284
|
+
else wakeAll();
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
var MAX_FAILURES_LOG_HINT = 8;
|
|
289
|
+
function cutToByteBudget(changes, maxScannedTs) {
|
|
290
|
+
let size = 0;
|
|
291
|
+
let cutIndex = changes.length;
|
|
292
|
+
for (let i = 0; i < changes.length; i++) {
|
|
293
|
+
const changeSize = JSON.stringify(changes[i]).length;
|
|
294
|
+
if (i > 0 && size + changeSize > BYTE_BUDGET) {
|
|
295
|
+
cutIndex = i;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
size += changeSize;
|
|
299
|
+
}
|
|
300
|
+
if (cutIndex === changes.length) return { batch: changes.slice(), advanceTs: maxScannedTs };
|
|
301
|
+
const cutTs = changes[cutIndex].ts;
|
|
302
|
+
let groupStart = cutIndex;
|
|
303
|
+
while (groupStart > 0 && changes[groupStart - 1].ts === cutTs) groupStart--;
|
|
304
|
+
if (groupStart === 0) {
|
|
305
|
+
let groupEnd = 1;
|
|
306
|
+
while (groupEnd < changes.length && changes[groupEnd].ts === changes[0].ts) groupEnd++;
|
|
307
|
+
return { batch: changes.slice(0, groupEnd), advanceTs: changes[0].ts };
|
|
308
|
+
}
|
|
309
|
+
const batch = changes.slice(0, groupStart);
|
|
310
|
+
return { batch, advanceTs: batch[batch.length - 1].ts };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/index.ts
|
|
314
|
+
function defineTriggers(opts) {
|
|
315
|
+
return defineComponent({
|
|
316
|
+
name: "triggers",
|
|
317
|
+
schema: triggersSchema,
|
|
318
|
+
modules: { _initCursor, _getCursor, _advanceCursor, _recordFailure, _pause, resume, _status },
|
|
319
|
+
driver: triggersDriver(opts)
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
export {
|
|
323
|
+
BREAKER_WINDOW_MS,
|
|
324
|
+
BYTE_BUDGET,
|
|
325
|
+
DEFAULT_BATCH_SIZE,
|
|
326
|
+
DEFAULT_MAX_DELIVERIES_PER_WINDOW,
|
|
327
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
328
|
+
cutToByteBudget,
|
|
329
|
+
defineTriggers,
|
|
330
|
+
triggersDriver,
|
|
331
|
+
triggersSchema
|
|
332
|
+
};
|
|
333
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/schema.ts","../src/modules.ts","../src/boot.ts","../src/driver.ts"],"sourcesContent":["import { defineComponent, type ComponentDefinition } from \"@helipod/component\";\nimport { triggersSchema } from \"./schema\";\nimport { _initCursor, _getCursor, _advanceCursor, _recordFailure, _pause, resume, _status } from \"./modules\";\nimport { triggersDriver, type TriggersOpts } from \"./driver\";\n\nexport * from \"./schema\";\nexport type { TriggerConfig, TriggersOpts, TriggersDriver } from \"./driver\";\nexport { triggersDriver, DEFAULT_BATCH_SIZE, BYTE_BUDGET, DEFAULT_MAX_DELIVERIES_PER_WINDOW, BREAKER_WINDOW_MS, cutToByteBudget } from \"./driver\";\nexport type { CursorRow } from \"./modules\";\nexport { MAX_CONSECUTIVE_FAILURES } from \"./modules\";\n\n/**\n * `defineTriggers(opts)` — the `@helipod/triggers` component: react to committed data changes\n * server-side, durably, without a queue — a trigger is a durable cursor over the MVCC log (the\n * design spec's core idea; see `docs/superpowers/specs/2025-10-16-onchange-triggers-design.md`).\n *\n * ```ts\n * // helipod.config.ts\n * import { defineTriggers } from \"@helipod/triggers\";\n * export default defineConfig({\n * components: [\n * defineTriggers({\n * messages: { handler: \"notifications:_onMessage\" }, // an internalMutation/internalAction\n * users: { handler: \"audit:_onUserChange\", fromStart: true },\n * }),\n * ],\n * });\n * ```\n *\n * Each key is a WATCHED TABLE NAME; `handler` is an internal (`_`-prefixed) mutation or action\n * receiving `{ changes: LogChange[] }` (`@helipod/component`'s `LogChange` — see its doc\n * comment for `op`/`oldDoc`/`changeId` semantics). No `context`/`contextType` — unlike\n * `@helipod/scheduler`, nothing calls a `ctx.triggers.*` facade from a mutation; the whole\n * surface is this declarative config plus the `triggers:resume` mutation\n * (`./modules.ts`) for un-pausing a tripped/failed trigger.\n *\n * Boot-time handler validation (unknown path / non-internal / wrong kind, all fail-fast) and\n * cursor initialization (tip, or `fromStart: true`'s ts-0 replay) both happen inside the driver's\n * `start()`, not a `ComponentDefinition.boot` step — see `./boot.ts`'s module doc comment for why\n * `BootContext` structurally can't do either.\n */\nexport function defineTriggers(opts: TriggersOpts): ComponentDefinition {\n return defineComponent({\n name: \"triggers\",\n schema: triggersSchema,\n modules: { _initCursor, _getCursor, _advanceCursor, _recordFailure, _pause, resume, _status },\n driver: triggersDriver(opts),\n });\n}\n","import { defineSchema, defineTable, v } from \"@helipod/values\";\n\n/**\n * The `@helipod/triggers` component schema (namespaced `triggers/*` when composed) — design\n * spec D2. One `cursors` row per configured trigger, keyed by the WATCHED TABLE NAME (`name`) —\n * see `../docs`/the design spec for why a table rename orphans the cursor rather than migrating\n * it.\n *\n * - `cursorTs`: the log coordinate this trigger has fully delivered through — see\n * `DriverContext.readLog`'s doc comment (`@helipod/component`) for why this is ALWAYS\n * `maxScannedTs`, never a delivered change's own `ts`.\n * - `state`: `\"running\"` (the driver dispatches this trigger on every relevant wake) or\n * `\"paused\"` (max-consecutive-failures or the circuit breaker tripped; the driver skips it\n * entirely until `triggers:resume` flips it back).\n * - `failureCount`: consecutive handler failures since the last successful delivery (or since\n * the trigger was created/resumed) — reset to 0 by `_advanceCursor` on every successful\n * delivery/quiet-table advance. PERSISTED (restart-safe), unlike the backoff retry timer\n * itself, which is in-memory only (see `driver.ts`'s module doc comment).\n * - `lastError` / `pausedReason`: operator-visible diagnostics; both cleared by `resume`.\n */\nexport const triggersSchema = defineSchema({\n cursors: defineTable({\n name: v.string(),\n cursorTs: v.number(),\n state: v.union(v.literal(\"running\"), v.literal(\"paused\")),\n failureCount: v.number(),\n lastError: v.optional(v.string()),\n pausedReason: v.optional(v.string()),\n }).index(\"by_name\", [\"name\"]),\n});\n","import { query, mutation } from \"@helipod/executor\";\nimport type { QueryCtx, MutationCtx } from \"@helipod/executor\";\nimport { computeBackoff } from \"@helipod/scheduler\";\n\n/**\n * Internal modules for `@helipod/triggers` — registered on `defineTriggers()`'s `modules` map\n * (reachable as `triggers:_initCursor` / `triggers:_getCursor` / etc.), consumed by the driver\n * loop (`./driver.ts`) via `DriverContext.runFunction`, which dispatches privileged\n * (`runtime-embedded/src/runtime.ts`'s `driverCtx.runFunction` sets `privileged: true`) —\n * privileged calls bypass namespace prefixing, so these modules use the fully-qualified table\n * name `\"triggers/cursors\"` (mirrors `@helipod/scheduler`'s `./modules.ts` module doc comment).\n *\n * `resume` is deliberately NOT `_`-prefixed (unlike every other module here): it's meant to be an\n * ordinary callable mutation an operator/the dashboard's function runner invokes directly\n * (`triggers:resume`, per the design spec) — see `defineTriggers`'s doc comment in `./index.ts`.\n */\n\n/** After this many CONSECUTIVE handler failures, a trigger pauses itself (design spec D2). */\nexport const MAX_CONSECUTIVE_FAILURES = 8;\n\n/** Drop `undefined`-valued keys before a `db.replace` (the wire codec rejects `undefined`). Mirrors `@helipod/scheduler`'s `compact` (duplicated per that package's own established convention — see its `modules.ts`/`facade.ts`). */\nfunction compact<T extends Record<string, unknown>>(obj: T): { [K in keyof T]: Exclude<T[K], undefined> } {\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;\n return out as { [K in keyof T]: Exclude<T[K], undefined> };\n}\n\nexport interface CursorRow {\n _id: string;\n name: string;\n cursorTs: number;\n state: \"running\" | \"paused\";\n failureCount: number;\n lastError?: string;\n pausedReason?: string;\n}\n\n/** `triggers:_getCursor` — a QUERY: the cursor row for `name`, or `null` if never initialized. */\nexport const _getCursor = query(async (ctx: QueryCtx, args: { name: string }): Promise<CursorRow | null> => {\n const rows = await ctx.db.query(\"triggers/cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n return (rows[0] as unknown as CursorRow) ?? null;\n});\n\n/** `triggers:_status` — a QUERY: every trigger's cursor row (dashboard/operator introspection). */\nexport const _status = query(async (ctx: QueryCtx): Promise<CursorRow[]> => {\n const rows = await ctx.db.query(\"triggers/cursors\", \"by_creation\").collect();\n return rows as unknown as CursorRow[];\n});\n\n/**\n * `triggers:_initCursor` — a MUTATION: insert-or-return-existing, so a driver's lazy\n * first-tick-for-this-trigger init (`./boot.ts`'s `ensureCursor`) is safe to call every time a\n * cursor row might not exist yet without a separate existence check racing the insert — the\n * single-writer OCC transactor serializes this mutation, so \"read, then conditionally insert\" is\n * atomic. `cursorTs` is the caller's already-computed starting point (`0` for `fromStart`, or the\n * log's current tip peeked via `readLog({ limit: 0 })` — see `./boot.ts`); this mutation has no\n * way to compute it itself (a mutation has no `readLog`).\n */\nexport const _initCursor = mutation(async (ctx: MutationCtx, args: { name: string; cursorTs: number }): Promise<CursorRow> => {\n const existing = await ctx.db.query(\"triggers/cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n if (existing[0]) return existing[0] as unknown as CursorRow;\n const id = await ctx.db.insert(\"triggers/cursors\", {\n name: args.name,\n cursorTs: args.cursorTs,\n state: \"running\",\n failureCount: 0,\n });\n return { _id: id as unknown as string, name: args.name, cursorTs: args.cursorTs, state: \"running\", failureCount: 0 };\n});\n\n/**\n * `triggers:_advanceCursor` — a MUTATION: moves `cursorTs` forward to `newCursorTs` (a delivered\n * batch's advance point, or a quiet-table's `maxScannedTs`) and clears the failure streak\n * (`failureCount: 0`, `lastError` cleared) — every call site only ever advances after a\n * CONFIRMED-clean state (a successful handler run, or no matches at all), so a reset here is\n * always correct, not just convenient.\n *\n * OCC-guarded by `expectedPrev` (the workflow `generationNumber` pattern, per the design spec): a\n * stale driver instance that's still mid-pass after its runtime stopped being the default-shard\n * holder (a fleet default-shard move — see `Driver.stop`/the driver lifecycle) must not\n * double-advance a cursor a NEWER driver instance already moved past. If `cursorTs !==\n * expectedPrev`, this no-ops — the stale caller's advance is silently dropped rather than\n * corrupting the newer instance's progress. (`_recordFailure`/`_pause`/`resume` below are NOT\n * OCC-guarded this way: over-counting a failure or double-pausing from a stale instance is merely\n * over-cautious, never a missed/duplicated delivery — only cursor advancement can silently skip\n * changes, so only it needs the guard.)\n */\nexport const _advanceCursor = mutation(\n async (ctx: MutationCtx, args: { name: string; newCursorTs: number; expectedPrev: number }): Promise<null> => {\n const rows = await ctx.db.query(\"triggers/cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n const row = rows[0];\n if (!row) return null; // shouldn't happen — the cursor is always initialized before this is called\n if ((row.cursorTs as number) !== args.expectedPrev) return null; // stale driver instance — no-op\n await ctx.db.replace(\n row._id as string,\n compact({ ...row, cursorTs: args.newCursorTs, failureCount: 0, lastError: undefined }),\n );\n return null;\n },\n);\n\n/**\n * `triggers:_recordFailure` — a MUTATION: increments `failureCount`, records `lastError`, and\n * either computes the next retry's backoff delay or — at `MAX_CONSECUTIVE_FAILURES` — pauses the\n * trigger (`pausedReason: \"max-failures\"`). The retry delay itself is NOT persisted (the driver's\n * backoff timer is in-memory — see `driver.ts`'s module doc comment: a restart retries\n * immediately, an accepted gap per the design spec); only the fact that a failure happened, and\n * how many in a row, survives a restart.\n *\n * Uses `ctx.random` (the mutation's own seeded PRNG) for `computeBackoff`'s jitter — the same\n * determinism-for-OCC-replay property `@helipod/scheduler`'s `_complete` relies on (see\n * `computeBackoff`'s doc comment, `@helipod/scheduler`): a replay of this exact mutation call\n * computes the exact same delay.\n */\nexport const _recordFailure = mutation(\n async (ctx: MutationCtx, args: { name: string; error: string }): Promise<{ paused: boolean; retryDelayMs: number }> => {\n const rows = await ctx.db.query(\"triggers/cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n const row = rows[0];\n if (!row) return { paused: false, retryDelayMs: 0 }; // shouldn't happen — defensive, mirrors _advanceCursor\n const failureCount = (row.failureCount as number) + 1;\n\n if (failureCount >= MAX_CONSECUTIVE_FAILURES) {\n await ctx.db.replace(\n row._id as string,\n compact({ ...row, failureCount, lastError: args.error, state: \"paused\", pausedReason: \"max-failures\" }),\n );\n return { paused: true, retryDelayMs: 0 };\n }\n\n await ctx.db.replace(row._id as string, compact({ ...row, failureCount, lastError: args.error }));\n return { paused: false, retryDelayMs: computeBackoff(failureCount, ctx.random) };\n },\n);\n\n/**\n * `triggers:_pause` — a MUTATION: the circuit breaker's write path (`./driver.ts`) — pauses a\n * trigger with an explicit `reason` (`\"circuit-breaker\"`) without touching `failureCount`/\n * `lastError` (unlike `_recordFailure`'s max-failures pause, a breaker trip isn't a handler\n * failure — the handler may have been succeeding every time, just too often). No-ops if already\n * paused (idempotent — a second trip, or a race with `_recordFailure`'s own pause, doesn't\n * clobber whichever `pausedReason` got there first).\n */\nexport const _pause = mutation(async (ctx: MutationCtx, args: { name: string; reason: string }): Promise<null> => {\n const rows = await ctx.db.query(\"triggers/cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n const row = rows[0];\n if (!row || row.state === \"paused\") return null;\n await ctx.db.replace(row._id as string, compact({ ...row, state: \"paused\", pausedReason: args.reason }));\n return null;\n});\n\n/**\n * `triggers:resume` — a MUTATION, NOT `_`-prefixed (see this file's module doc comment): flips a\n * paused trigger back to `\"running\"` and clears its failure/pause diagnostics, so it starts its\n * failure streak and breaker window fresh. No-ops if the trigger doesn't exist. Callable from the\n * dashboard's function runner (or directly by an app/operator) per the design spec — via the\n * ORDINARY `runtime.run()`/`/api/run` path (same as any other client-callable mutation), NOT the\n * driver's privileged `DriverContext.runFunction` path. That matters here: unlike every OTHER\n * module in this file (driver-only, dispatched privileged, and so using the fully-qualified\n * `\"triggers/cursors\"` table name — see this file's module doc comment), a namespaced call runs\n * with `ctx.db` auto-prefixing BARE table names to this component's own namespace — so this one\n * function uses the bare `\"cursors\"`, not `\"triggers/cursors\"` (which a namespaced call can't\n * resolve: namespace prefixing only applies to names that AREN'T already `/`-qualified).\n */\nexport const resume = mutation(async (ctx: MutationCtx, args: { name: string }): Promise<null> => {\n const rows = await ctx.db.query(\"cursors\", \"by_name\").eq(\"name\", args.name).take(1).collect();\n const row = rows[0];\n if (!row) return null;\n await ctx.db.replace(\n row._id as string,\n compact({ ...row, state: \"running\", failureCount: 0, lastError: undefined, pausedReason: undefined }),\n );\n return null;\n});\n","import type { DriverContext } from \"@helipod/component\";\nimport type { TriggersOpts } from \"./driver\";\n\n/**\n * Boot-time work for `@helipod/triggers`: handler-path validation (fail-fast) and cursor\n * initialization (tip or `fromStart`). Both are called from `driver.ts`'s `start()`, NOT wired as\n * a `ComponentDefinition.boot` step — despite the file name, matching the brief's file layout.\n * Here's why, spelled out so a future reader doesn't wonder:\n *\n * `ComponentDefinition.boot` runs with a `BootContext` (`packages/component/src/\n * define-component.ts`), which is exactly `{ db, now }` — a namespaced, non-user `db` and a\n * wall-clock reading. It has neither `readLog` (needed to peek the log's current tip for a new,\n * non-`fromStart` trigger — see `ensureCursor` below) nor `functionKind` (needed to validate a\n * configured `handler` path resolves to a real registered mutation/action — see\n * `validateHandlers` below). Both capabilities live ONLY on `DriverContext`\n * (`packages/component/src/define-component.ts`), which a component's `driver.start(ctx)` DOES\n * receive. Since `EmbeddedRuntime.create()` runs boot steps and THEN awaits every driver's\n * `start()` in a synchronous `for` loop before returning (`packages/runtime-embedded/src/\n * runtime.ts`), a validation error thrown from `start()` still fails the whole runtime\n * construction — \"boot fails fast\" holds end-to-end even though the check itself runs a beat\n * later than a literal `ComponentDefinition.boot` step would. Extending `BootContext` with\n * `readLog`/`functionKind` just to move this code into an actual boot step would be a needless\n * cross-package surface change for a purely cosmetic win — `driver.start()` already gives the\n * exact capabilities this needs.\n */\n\n/** Internal-path convention check — mirrors `runtime-embedded/src/runtime.ts`'s private `isInternalPath` (not exported, so re-implemented here rather than reaching across a package boundary for a one-line predicate). A path is \"internal\" when some `:`-separated segment starts with `_` (e.g. `\"notifications:_onMessage\"`). */\nfunction isInternalPath(path: string): boolean {\n return path.split(\":\").some((seg) => seg.startsWith(\"_\"));\n}\n\n/**\n * Validates every configured trigger's `handler` path BEFORE the driver starts dispatching —\n * three fail-fast, instructive errors (design spec D2 / the error-handling table):\n * 1. the path isn't a registered function at all (\"unknown handler path\"),\n * 2. the path isn't internal — trigger handlers must not be directly client-callable, the same\n * `internalMutation`/`internalAction` convention Convex uses (\"non-internal\"),\n * 3. the path resolves but isn't a mutation or action (e.g. a query — \"wrong kind\").\n *\n * Throws on the FIRST violation found (config iteration order) rather than collecting every\n * error — one bad handler is enough to refuse to start, and a single clear message beats a wall\n * of them.\n */\nexport function validateHandlers(ctx: DriverContext, opts: TriggersOpts): void {\n if (!ctx.functionKind) {\n throw new Error(\n \"@helipod/triggers: the runtime's DriverContext does not provide functionKind — handler validation \" +\n \"cannot run. This means the composed runtime is older than @helipod/component's triggers support; \" +\n \"upgrade @helipod/runtime-embedded.\",\n );\n }\n for (const [table, cfg] of Object.entries(opts)) {\n const kind = ctx.functionKind(cfg.handler);\n if (kind === undefined) {\n throw new Error(\n `@helipod/triggers: trigger \"${table}\" references handler \"${cfg.handler}\", which is not a registered ` +\n `function. Check the path matches an exported mutation or action (e.g. \"notifications:_onMessage\").`,\n );\n }\n if (!isInternalPath(cfg.handler)) {\n throw new Error(\n `@helipod/triggers: trigger \"${table}\"'s handler \"${cfg.handler}\" must be an internal function — a ` +\n `module or function name segment prefixed with \"_\" (e.g. \"notifications:_onMessage\"). Trigger handlers ` +\n `are driven only by the trigger loop and must not be directly client-callable.`,\n );\n }\n if (kind !== \"mutation\" && kind !== \"action\") {\n throw new Error(\n `@helipod/triggers: trigger \"${table}\"'s handler \"${cfg.handler}\" is a ${kind}, not a mutation or ` +\n `action. Trigger handlers must be an internal mutation or action.`,\n );\n }\n }\n}\n\n/**\n * Ensures a cursor row exists for `name`, creating one lazily on first-ever call if it doesn't:\n * `fromStart` seeds `cursorTs: 0` (replay every existing revision — see the design spec's D3\n * cost note); otherwise seeds at `tipIfNew` — the log's tip AS OF BEFORE this call, already\n * peeked by the caller (`driver.ts`'s `runPass`, via the `limit: 0` \"peek the bound, don't scan\"\n * idiom — `DriverContext.readLog`'s doc comment, `@helipod/component`).\n *\n * `tipIfNew` is a CALLER-supplied value, not peeked again in here, on purpose: `runPass` peeks\n * its own `targetBound` (the ceiling this pass will drain up to) BEFORE calling this function, and\n * reuses that exact same peek here. If this function peeked independently, AFTER its own\n * `_initCursor` insert had already landed (a write, which bumps the log's tip by one), a fresh\n * peek taken at that point would see that very insert — creating a self-chasing loop where a\n * brand-new trigger's first pass perpetually discovers \"one more\" entry that is actually its OWN\n * housekeeping write. Reusing the caller's PRE-write peek value sidesteps this: `_initCursor`'s\n * own commit lands strictly AFTER `tipIfNew` was captured, so it can never be mistaken for part\n * of what this pass needs to catch up to.\n *\n * Called at the top of every pass (`driver.ts`), not cached after the first success: a resumed\n * trigger's `state` can flip back to `\"running\"` from an external `triggers:resume` call at any\n * time, and the driver must observe that on its next wake regardless of whether it already\n * initialized this cursor earlier in the process's lifetime.\n */\nexport async function ensureCursor(\n ctx: DriverContext,\n name: string,\n fromStart: boolean,\n tipIfNew: number,\n): Promise<{ cursorTs: number; state: \"running\" | \"paused\" }> {\n const existing = (await ctx.runFunction(\"triggers:_getCursor\", { name })) as\n | { cursorTs: number; state: \"running\" | \"paused\" }\n | null;\n if (existing) return existing;\n\n const cursorTs = fromStart ? 0 : tipIfNew;\n const created = (await ctx.runFunction(\"triggers:_initCursor\", { name, cursorTs })) as {\n cursorTs: number;\n state: \"running\" | \"paused\";\n };\n return created;\n}\n","import type { Driver, DriverContext, LogChange } from \"@helipod/component\";\nimport type { JSONValue } from \"@helipod/values\";\nimport { validateHandlers, ensureCursor } from \"./boot\";\n\nexport interface TriggerConfig {\n /** App fn path — an internal (`_`-prefixed) mutation or action; see `./boot.ts`'s `validateHandlers`. */\n handler: string;\n /** Max changes per handler invocation. Default `DEFAULT_BATCH_SIZE` (64). */\n batchSize?: number;\n /** Start at log ts 0 (replay every existing revision) instead of the tip. Default `false`. */\n fromStart?: boolean;\n /** Circuit-breaker threshold: deliveries allowed per `BREAKER_WINDOW_MS`. Default `DEFAULT_MAX_DELIVERIES_PER_WINDOW` (1000). */\n maxDeliveriesPerWindow?: number;\n}\n\nexport type TriggersOpts = Record<string, TriggerConfig>;\n\n/** Design spec D2 default. */\nexport const DEFAULT_BATCH_SIZE = 64;\n/** Design spec D2: \"~1MB serialized — full docs travel in the args; cut the batch early when exceeded.\" Approximated via `JSON.stringify(...).length` (UTF-16 code units, not exact bytes) — a ballpark budget, not an exact enforcement. */\nexport const BYTE_BUDGET = 1_000_000;\n/** Design spec D2 default (the circuit breaker). */\nexport const DEFAULT_MAX_DELIVERIES_PER_WINDOW = 1000;\n/** Design spec D2: the breaker's fixed window size. */\nexport const BREAKER_WINDOW_MS = 10_000;\n/**\n * The driver's ONLY periodic timer — everything else is reactive. Backstops an external\n * `triggers:resume` call with no accompanying watched-table write to react to (see `start()`'s\n * `onCommit` filter for why resume isn't wired reactively) by re-`wakeAll()`ing on this cadence;\n * also a general defense-in-depth re-check, mirroring `@helipod/scheduler`'s `SWEEP_MS`. A\n * test drives this deterministically via `__tick()` (no `name`) rather than waiting on this timer\n * — see `TriggersDriver`'s doc comment.\n */\nexport const BEAT_MS = 30_000;\n\n/**\n * A `triggersDriver()` also exposes:\n * - `__tick(name?)`: a deterministic test seam for one pass of trigger `name` (or every\n * configured trigger, awaited together, if omitted) — no real timers. Calling it with no\n * `name` is also the deterministic stand-in for \"the periodic beat fired\" (`BEAT_MS`,\n * `armBeat` below) — both do exactly `wakeAll()`, so a test never needs to wait out the real\n * 30s timer to prove a resumed trigger gets picked back up.\n * - `__wake(name?)`: the same fire-and-forget signal `DriverContext.onCommit`/a retry timer use\n * internally, exposed so a test can simulate one landing at a precise moment.\n */\nexport interface TriggersDriver extends Driver {\n __tick: (name?: string) => Promise<void>;\n __wake: (name?: string) => void;\n}\n\n/**\n * `@helipod/triggers`' driver — one independent event loop PER configured trigger (design spec\n * D2), not a single shared loop: each trigger name gets its own `running`/`pendingWake`/`inFlight`\n * coalescing state (mirroring `@helipod/scheduler`'s `schedulerDriver` shape, but keyed by\n * name), so a slow handler on trigger A can never head-of-line-block trigger B's independent\n * backlog — they're just separate promise chains woken by the same commit fan-out.\n *\n * Two wake sources per trigger, no fixed-interval polling:\n * - **Reactive**: `DriverContext.onCommit` fires `wake(name)` for every trigger `name` whose\n * OWN watched table appears in the commit's `tables` — and for EVERY trigger when\n * `\"triggers/cursors\"` itself is touched (an external `triggers:resume` call, which any\n * trigger's paused state could be waiting on).\n * - **Timer**: a failed delivery arms a single retry timer for that trigger, per\n * `computeBackoff` (`@helipod/scheduler`) — see `_recordFailure`'s doc comment (`./modules.ts`)\n * for why the delay itself is in-memory, not persisted.\n *\n * Each trigger's attempt (`runOnePass`) loops internally — draining quiet-table progress and\n * full matched batches alike — until `cursorTs` reaches the bound captured at the START of that\n * attempt (`targetBound`), a delivery fails, or the breaker/max-failures pauses it; `runPass`\n * wraps that in an outer `do..while(pendingWake)` (scheduler's exact shape) so a wake landing\n * mid-drain gets one more FULL attempt (a fresh `targetBound` peek included) rather than being\n * silently dropped. This is the precise, non-spinning inference the T1 handoff calls for:\n * `readLog` exposes no explicit \"did the scan hit its limit\" flag, so rather than guess from\n * `changes.length` alone, each attempt just keeps asking \"is there more, up to where I started?\"\n * (bounded, since that target can never move against a trigger's own writes — see `runPass`'s\n * doc comment) until the answer is unambiguously \"no.\"\n */\nexport function triggersDriver(opts: TriggersOpts): TriggersDriver {\n let ctx: DriverContext;\n let stopped = false;\n\n const names = Object.keys(opts);\n const running = new Map<string, boolean>();\n const pendingWake = new Map<string, boolean>();\n const inFlight = new Map<string, Promise<void>>();\n const retryTimers = new Map<string, number>();\n /** In-memory only (design spec D2: \"window state need not persist\") — a fixed 10s window per trigger. */\n const breakerWindows = new Map<string, { windowStart: number; count: number }>();\n /** In-memory backoff gate: a trigger with a pending retry timer skips dispatch until the timer's `atMs` — see the module doc comment for why this delay isn't persisted. */\n const nextAttemptAt = new Map<string, number>();\n\n function wakeAll(): void {\n for (const name of names) wake(name);\n }\n\n function wake(name: string): void {\n if (stopped) return;\n iterate(name).catch((e: unknown) => {\n console.error(`[triggers] driver iteration for \"${name}\" failed:`, e);\n });\n }\n\n function iterate(name: string): Promise<void> {\n if (running.get(name)) {\n // Mirrors `@helipod/scheduler`'s coalescing (see its `driver.ts` for the full rationale):\n // a wake landing mid-pass sets a bit the in-flight pass checks before releasing `running`,\n // so it loops for one more fresh pass instead of a stale re-arm stranding new work.\n pendingWake.set(name, true);\n return inFlight.get(name) ?? Promise.resolve();\n }\n running.set(name, true);\n const pass = runPass(name).finally(() => {\n running.set(name, false);\n inFlight.delete(name);\n // Closes a residual micro-window (mirrors `@helipod/scheduler`'s `iterate()`): a wake can\n // land between `runPass`'s own internal `do..while(pendingWake)` loop (below) making its\n // final check and THIS `finally` running. Without this, that wake would set `pendingWake`\n // but nothing would ever consume it.\n if (pendingWake.get(name)) {\n pendingWake.set(name, false);\n void wake(name);\n }\n });\n inFlight.set(name, pass);\n return pass;\n }\n\n /**\n * Loops `runOnePass` until an attempt completes with no wake pending — a wake that arrives\n * mid-drain (a real external write to `name`'s watched table landing while this pass is\n * mid-`readLog`, or a concurrent caller's own `iterate(name)` call coalescing into this one) sets\n * `pendingWake`, and this loop re-runs `runOnePass` (with a FRESH `targetBound` peek) instead of\n * returning with a stale view. Mirrors `@helipod/scheduler`'s `runPass` shape exactly — this\n * is now safe to do (see `start()`'s `onCommit` filter): `pendingWake` can only be set here by a\n * GENUINE external event, never by this trigger's own routine cursor-bookkeeping writes (those\n * no longer reactively wake anything, precisely to avoid this loop chasing its own tail).\n */\n async function runPass(name: string): Promise<void> {\n let iterations = 0;\n do {\n pendingWake.set(name, false);\n await runOnePass(name);\n // Defensive cap: real cascades here are bounded (each iteration only fires from a genuine\n // NEW external wake), but a runaway is a bug we'd rather surface loudly than spin forever on.\n if (++iterations > 1000) {\n console.error(`[triggers] \"${name}\": runPass exceeded 1000 iterations — bailing (possible bug).`);\n return;\n }\n } while (pendingWake.get(name));\n }\n\n /** Drains trigger `name` up to the log bound captured ONCE at the start of THIS attempt (`targetBound` below) — see `runPass`'s doc comment for why a live-refreshed bound would self-perpetuate. */\n async function runOnePass(name: string): Promise<void> {\n const cfg = opts[name]!;\n const waitUntil = nextAttemptAt.get(name);\n if (waitUntil !== undefined && ctx.now() < waitUntil) return; // still backing off — the retry timer will wake us\n\n // Peeked BEFORE `ensureCursor` — deliberately, not after — so a brand-new trigger's OWN\n // `_initCursor` insert (a write) can never land inside the very range this same pass is about\n // to consider \"unscanned.\" The `limit: 0` \"peek the bound, don't scan\" idiom (see\n // `DriverContext.readLog`'s doc comment) is an O(1) read, so capturing this target costs\n // nothing extra; `ensureCursor` reuses this exact value (`tipIfNew`) rather than peeking\n // again — see its doc comment (`./boot.ts`) for the self-chasing loop this specifically avoids.\n const { maxScannedTs: targetBound } = await ctx.readLog({ afterTs: 0, tables: [], limit: 0 });\n\n const cursor = await ensureCursor(ctx, name, cfg.fromStart === true, targetBound);\n let cursorTs = cursor.cursorTs;\n if (cursor.state !== \"running\") return; // paused — nothing to do until `triggers:resume`\n\n const batchSize = cfg.batchSize ?? DEFAULT_BATCH_SIZE;\n const maxPerWindow = cfg.maxDeliveriesPerWindow ?? DEFAULT_MAX_DELIVERIES_PER_WINDOW;\n\n while (cursorTs < targetBound) {\n const { changes, maxScannedTs } = await ctx.readLog({ afterTs: cursorTs, tables: [name], limit: batchSize });\n\n if (changes.length === 0) {\n if (maxScannedTs <= cursorTs) break; // defensive — the `while` guard above should already prevent this\n await ctx.runFunction(\"triggers:_advanceCursor\", { name, newCursorTs: maxScannedTs, expectedPrev: cursorTs });\n cursorTs = maxScannedTs;\n continue; // more of the (quiet) log, up to `targetBound`, may remain beyond this call's own limit\n }\n\n const { batch, advanceTs } = cutToByteBudget(changes, maxScannedTs);\n\n // `ctx.now()` read FRESH per delivery (not hoisted above the loop) so the breaker's 10s\n // window is measured against real elapsed time across a long-running drain, not a single\n // stale timestamp from when this pass started.\n if (recordDeliveryAndCheckBreaker(name, ctx.now(), maxPerWindow)) {\n await ctx.runFunction(\"triggers:_pause\", { name, reason: \"circuit-breaker\" });\n console.error(\n `[triggers] \"${name}\" paused: circuit breaker tripped (> ${maxPerWindow} deliveries within ${BREAKER_WINDOW_MS}ms) — a self-recursive or runaway handler, most likely.`,\n );\n return;\n }\n\n try {\n // `LogChange[]` isn't structurally a `JSONValue` (it's a concrete interface, not an index\n // signature) even though every field IS JSON-safe — same bridging cast\n // `@helipod/scheduler`'s driver uses for a job's arbitrary `result.value` (see its\n // `driver.ts`).\n await ctx.runFunction(cfg.handler, { changes: batch } as unknown as JSONValue);\n } catch (e) {\n const result = (await ctx.runFunction(\"triggers:_recordFailure\", { name, error: String(e) })) as {\n paused: boolean;\n retryDelayMs: number;\n };\n if (result.paused) {\n console.error(`[triggers] \"${name}\" paused after ${MAX_FAILURES_LOG_HINT} consecutive failures: ${String(e)}`);\n nextAttemptAt.delete(name);\n } else {\n const retryAt = ctx.now() + result.retryDelayMs;\n nextAttemptAt.set(name, retryAt);\n armRetryTimer(name, retryAt);\n }\n return; // same cursorTs — the exact same batch (by changeId) redelivers next attempt\n }\n\n await ctx.runFunction(\"triggers:_advanceCursor\", { name, newCursorTs: advanceTs, expectedPrev: cursorTs });\n cursorTs = advanceTs;\n nextAttemptAt.delete(name); // a successful delivery clears any stale backoff gate\n // loop again — `advanceTs` may be < `maxScannedTs` (a byte-budget cut), or there may simply\n // be more beyond what this one `readLog` call scanned.\n }\n }\n\n function armRetryTimer(name: string, atMs: number): void {\n const existing = retryTimers.get(name);\n if (existing !== undefined) ctx.clearTimer(existing);\n const handle = ctx.setTimer(atMs, () => wake(name));\n retryTimers.set(name, handle);\n }\n\n function recordDeliveryAndCheckBreaker(name: string, now: number, maxPerWindow: number): boolean {\n let w = breakerWindows.get(name);\n if (!w || now - w.windowStart >= BREAKER_WINDOW_MS) {\n w = { windowStart: now, count: 0 };\n breakerWindows.set(name, w);\n }\n w.count++;\n return w.count > maxPerWindow;\n }\n\n let unsubscribeCommit: (() => void) | null = null;\n let beatTimer: number | null = null;\n\n // The driver's periodic backstop — the ONLY thing an external `triggers:resume` call relies on\n // to be noticed (see `start()`'s `onCommit` filter below for why resume ISN'T wired reactively).\n // Re-arms itself after every fire, mirroring `@helipod/scheduler`'s `armSweep`/`sweepOnce`.\n function armBeat(): void {\n if (stopped) return;\n if (beatTimer !== null) ctx.clearTimer(beatTimer);\n // `backstopMs` (not `BEAT_MS` raw): the beat is a pure backstop poll, never next-work — the call\n // site is how a driver declares that, so a host where every wake costs a cold start can stretch\n // it. The documented cost there: an external `triggers:resume` is noticed on the STRETCHED\n // cadence, not within ~30s.\n beatTimer = ctx.setTimer(ctx.now() + ctx.backstopMs(BEAT_MS), () => {\n wakeAll();\n armBeat();\n });\n }\n\n return {\n name: \"triggers\",\n start(c) {\n ctx = c;\n validateHandlers(ctx, opts); // fail-fast — see ./boot.ts for why this can't be a literal boot step\n unsubscribeCommit = ctx.onCommit((inv) => {\n // Deliberately NOT waking on `\"triggers/cursors\"` writes here (unlike\n // `@helipod/scheduler`'s onCommit filter, which DOES react to its own control-table\n // writes): `triggers:_advanceCursor`/`_recordFailure`/`_pause` are THEMSELVES commits to\n // `triggers/cursors`, so reacting to them would make a trigger's own routine cursor\n // bookkeeping re-wake itself forever, one tick at a time — see this file's `runPass` doc\n // comment for the full mechanics. An external `triggers:resume` call (the only reason to\n // care about a `triggers/cursors` write from OUTSIDE this loop) is instead picked up by\n // the periodic beat (`armBeat` below) — eventual, not instant, but resume is an infrequent\n // operator action, not a latency-sensitive path.\n for (const name of names) if (inv.tables.includes(name)) wake(name);\n });\n wakeAll(); // pick up any backlog since the last run (a restart, or a resume that landed while stopped)\n armBeat();\n },\n stop() {\n stopped = true;\n unsubscribeCommit?.();\n unsubscribeCommit = null;\n if (beatTimer !== null) {\n ctx.clearTimer(beatTimer);\n beatTimer = null;\n }\n for (const handle of retryTimers.values()) ctx.clearTimer(handle);\n retryTimers.clear();\n },\n __tick: async (name?: string) => {\n if (name !== undefined) {\n await iterate(name);\n return;\n }\n await Promise.all(names.map((n) => iterate(n)));\n },\n __wake: (name?: string) => {\n if (name !== undefined) wake(name);\n else wakeAll();\n },\n };\n}\n\n/** Referenced only in a log line — kept as a named constant so the message and the real threshold (`MAX_CONSECUTIVE_FAILURES`, `./modules.ts`) can't silently drift; `./modules.ts` isn't imported here to avoid a needless cross-file coupling for one log string. */\nconst MAX_FAILURES_LOG_HINT = 8;\n\n/**\n * Cuts `changes` (already fully scanned by `readLog`, ascending ts) to fit `BYTE_BUDGET`, WITHOUT\n * ever splitting a ts group — a single commit can touch multiple documents in the same watched\n * table, producing multiple `LogChange`s at the same `ts`; delivering some of a group while\n * excluding the rest and then advancing past that `ts` would silently and permanently skip the\n * excluded ones (the same invariant `readLog`'s own `limit` handling enforces internally — see\n * `runtime.ts`'s `readLog`).\n *\n * Degenerate case: the FIRST ts group alone already exceeds the budget (one huge document, or\n * many documents committed together). Mirrors `readLog`'s own same-ts-group handling: deliver it\n * whole, unbounded — a batch of size 1 that's still too big can't be shrunk further, and\n * indefinitely refusing to deliver it would stall the trigger forever.\n *\n * Returns `advanceTs`: `maxScannedTs` when nothing was cut (the common case — the whole scanned\n * window fit), or the ts of the last INCLUDED change when a cut occurred (never `maxScannedTs`,\n * which would skip the excluded, not-yet-delivered tail).\n */\nexport function cutToByteBudget(\n changes: readonly LogChange[],\n maxScannedTs: number,\n): { batch: LogChange[]; advanceTs: number } {\n let size = 0;\n let cutIndex = changes.length;\n for (let i = 0; i < changes.length; i++) {\n const changeSize = JSON.stringify(changes[i]).length;\n if (i > 0 && size + changeSize > BYTE_BUDGET) {\n cutIndex = i;\n break;\n }\n size += changeSize;\n }\n if (cutIndex === changes.length) return { batch: changes.slice(), advanceTs: maxScannedTs };\n\n const cutTs = changes[cutIndex]!.ts;\n let groupStart = cutIndex;\n while (groupStart > 0 && changes[groupStart - 1]!.ts === cutTs) groupStart--;\n\n if (groupStart === 0) {\n // The very first ts group is itself over budget — deliver it whole regardless (see doc comment).\n let groupEnd = 1;\n while (groupEnd < changes.length && changes[groupEnd]!.ts === changes[0]!.ts) groupEnd++;\n return { batch: changes.slice(0, groupEnd), advanceTs: changes[0]!.ts };\n }\n\n const batch = changes.slice(0, groupStart);\n return { batch, advanceTs: batch[batch.length - 1]!.ts };\n}\n"],"mappings":"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","names":["v"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@helipod/triggers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@helipod/component": "0.1.0",
|
|
21
|
+
"@helipod/executor": "0.1.0",
|
|
22
|
+
"@helipod/scheduler": "0.1.0",
|
|
23
|
+
"@helipod/values": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@helipod/docstore-sqlite": "0.1.0",
|
|
27
|
+
"@helipod/runtime-embedded": "0.1.0",
|
|
28
|
+
"@types/node": "^22.10.5",
|
|
29
|
+
"tsup": "^8.3.5",
|
|
30
|
+
"typescript": "^5.7.2",
|
|
31
|
+
"vitest": "^2.1.8"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/helipod-sh/helipod.git",
|
|
42
|
+
"directory": "components/triggers"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/helipod-sh/helipod"
|
|
45
|
+
}
|