queen-mq 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/client-v2/builders/QueueBuilder.js +10 -0
  2. package/client-v2/index.js +15 -0
  3. package/client-v2/streams/Stream.js +392 -0
  4. package/client-v2/streams/helpers/rateLimiter.js +138 -0
  5. package/client-v2/streams/operators/AggregateOperator.js +87 -0
  6. package/client-v2/streams/operators/FilterOperator.js +18 -0
  7. package/client-v2/streams/operators/FlatMapOperator.js +21 -0
  8. package/client-v2/streams/operators/ForeachOperator.js +29 -0
  9. package/client-v2/streams/operators/GateOperator.js +85 -0
  10. package/client-v2/streams/operators/KeyByOperator.js +23 -0
  11. package/client-v2/streams/operators/MapOperator.js +27 -0
  12. package/client-v2/streams/operators/ReduceOperator.js +196 -0
  13. package/client-v2/streams/operators/SinkOperator.js +54 -0
  14. package/client-v2/streams/operators/WindowCronOperator.js +83 -0
  15. package/client-v2/streams/operators/WindowSessionOperator.js +223 -0
  16. package/client-v2/streams/operators/WindowSlidingOperator.js +103 -0
  17. package/client-v2/streams/operators/WindowTumblingOperator.js +94 -0
  18. package/client-v2/streams/operators/_windowCommon.js +77 -0
  19. package/client-v2/streams/operators/index.js +18 -0
  20. package/client-v2/streams/runtime/Runner.js +955 -0
  21. package/client-v2/streams/runtime/StreamsHttpClient.js +83 -0
  22. package/client-v2/streams/runtime/cycle.js +36 -0
  23. package/client-v2/streams/runtime/register.js +48 -0
  24. package/client-v2/streams/runtime/state.js +33 -0
  25. package/client-v2/streams/util/backoff.js +23 -0
  26. package/client-v2/streams/util/configHash.js +33 -0
  27. package/client-v2/streams/util/logger.js +29 -0
  28. package/package.json +14 -4
  29. package/test-v2/run.js +26 -2
  30. package/test-v2/stream/_helpers.js +190 -0
  31. package/test-v2/stream/combined.js +148 -0
  32. package/test-v2/stream/cron.js +98 -0
  33. package/test-v2/stream/eventTime.js +220 -0
  34. package/test-v2/stream/index.js +25 -0
  35. package/test-v2/stream/operators.js +207 -0
  36. package/test-v2/stream/recovery.js +141 -0
  37. package/test-v2/stream/session.js +144 -0
  38. package/test-v2/stream/sliding.js +75 -0
  39. package/test-v2/stream/throughput.js +153 -0
  40. package/test-v2/stream/tumbling.js +315 -0
  41. package/test-v2/streams-unit/configHash.test.js +102 -0
  42. package/test-v2/streams-unit/cycle.test.js +200 -0
  43. package/test-v2/streams-unit/e2e.test.js +221 -0
  44. package/test-v2/streams-unit/eventTime.test.js +149 -0
  45. package/test-v2/streams-unit/fakeServer.js +165 -0
  46. package/test-v2/streams-unit/operators.test.js +476 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * FlatMapOperator — emit zero or more values per input record.
3
+ *
4
+ * The user fn returns an array of values; the operator wraps each in an
5
+ * envelope sharing the source message's metadata (so subsequent operators
6
+ * still see the original partition_id etc.).
7
+ */
8
+ export class FlatMapOperator {
9
+ constructor(fn) {
10
+ this.kind = 'flatMap'
11
+ this.fn = fn
12
+ }
13
+
14
+ async apply(envelope) {
15
+ const out = await this.fn(envelope.msg, envelope.ctx)
16
+ if (!Array.isArray(out)) {
17
+ throw new Error('flatMap fn must return an array (got ' + typeof out + ')')
18
+ }
19
+ return out.map(value => ({ ...envelope, value }))
20
+ }
21
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ForeachOperator — terminal at-least-once side-effect.
3
+ *
4
+ * The runtime invokes the user fn for each emitted record after state has
5
+ * been computed but BEFORE the cycle commits the source ack. If `fn`
6
+ * resolves successfully for all records, the cycle commits as normal. If
7
+ * `fn` throws, the cycle is aborted and the messages will be redelivered
8
+ * by Queen's lease/retry mechanism.
9
+ *
10
+ * User-fn signature: `(value, ctx?) => void|Promise<void>`
11
+ * value: the post-reducer value (e.g. an aggregate { count, sum, ... })
12
+ * ctx: { partition, partitionId, key, windowKey, windowStart, windowEnd }
13
+ *
14
+ * For exactly-once external effects, use `.to(queue)` and have a separate
15
+ * worker drain the sink queue with a transactional ack.
16
+ */
17
+ export class ForeachOperator {
18
+ constructor(fn) {
19
+ this.kind = 'foreach'
20
+ this.fn = fn
21
+ this.config = { kind: 'foreach' }
22
+ }
23
+
24
+ async runEffects(entries) {
25
+ for (const entry of entries) {
26
+ await this.fn(entry.value, entry.ctx)
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * GateOperator — per-message ALLOW/DENY decision with persistent per-key state.
3
+ *
4
+ * Used to build rate limiters, throttlers, fairness gates, circuit breakers,
5
+ * and any other "should this message proceed RIGHT NOW?" pattern.
6
+ *
7
+ * Semantics
8
+ * ---------
9
+ * The runtime processes a popped batch sequentially in source order. For each
10
+ * message, it builds a `ctx` object with mutable `state` (loaded from
11
+ * `queen_streams.state` for the message's key) and invokes the user fn.
12
+ *
13
+ * .gate((msg, ctx) => boolean)
14
+ *
15
+ * Returning `true` (or `{allow:true}`):
16
+ * - The mutated `ctx.state` is persisted as part of the cycle.
17
+ * - Downstream operators (sink, foreach) receive this message.
18
+ * - The message counts toward the cycle's ack count.
19
+ *
20
+ * Returning `false` (or `{allow:false}`):
21
+ * - The mutated state is DISCARDED for this message (it didn't "happen").
22
+ * - The runner stops processing the rest of the batch.
23
+ * - The cycle commits with `release_lease=false` and `ack.count = K`
24
+ * (the count of allowed messages BEFORE this one).
25
+ * - The message and its successors stay leased until natural lease
26
+ * expiry; the broker then redelivers them to a runner in their
27
+ * original partition order.
28
+ *
29
+ * If K === 0 (the very first message is denied), the runner skips the
30
+ * cycle entirely — no state, no push, no ack — and the lease times out
31
+ * naturally.
32
+ *
33
+ * Ordering
34
+ * --------
35
+ * FIFO per-partition is preserved by construction: the same partition lease
36
+ * is held continuously across the deny → expiry → redeliver cycle, so the
37
+ * denied messages never overtake messages that arrived after them.
38
+ *
39
+ * State key
40
+ * ---------
41
+ * State is keyed by the envelope's `key` (typically the source partition_id
42
+ * unless `.keyBy()` overrides it). For a rate limiter, the natural setup is:
43
+ * - source queue partitioned by the rate-limit key (e.g. tenantId)
44
+ * - no .keyBy() needed (key defaults to partitionId)
45
+ *
46
+ * If the key DIVERGES from the partition (cross-partition keying), the same
47
+ * cross-worker contention warning that applies to .reduce() applies here too:
48
+ * two workers may hold leases for different partitions but write the same
49
+ * (query_id, partition_id, key) row. For rate limiting this is almost always
50
+ * a configuration mistake — keep partition == limit key.
51
+ *
52
+ * Why a dedicated operator
53
+ * ------------------------
54
+ * The "stop the batch + don't release the lease" semantics has no analog in
55
+ * the existing operators (.map / .filter / .reduce all process the full batch).
56
+ * Modelling it as a return-value side effect of a regular .map would be
57
+ * surprising; a dedicated `.gate()` makes the intent and the cycle behaviour
58
+ * explicit at the chain site.
59
+ */
60
+ export class GateOperator {
61
+ constructor(fn) {
62
+ if (typeof fn !== 'function') {
63
+ throw new Error('.gate(fn) requires a function')
64
+ }
65
+ this.kind = 'gate'
66
+ this.fn = fn
67
+ }
68
+
69
+ /**
70
+ * Evaluate the gate for a single envelope. Called by the runtime, not by
71
+ * other operators. Returns `{ allow: boolean }`.
72
+ *
73
+ * The user fn receives `(msg, ctx)` where ctx.state is mutable and ctx
74
+ * also carries `streamTimeMs` (system clock) and `partitionId` for
75
+ * observability / time-based bucket math.
76
+ */
77
+ async evaluate(envelope, ctx) {
78
+ const out = await this.fn(envelope.value, ctx)
79
+ if (out === true || out === false) return { allow: out }
80
+ if (out && typeof out === 'object' && typeof out.allow === 'boolean') {
81
+ return { allow: out.allow }
82
+ }
83
+ throw new Error('.gate(fn) must return boolean or { allow: boolean }')
84
+ }
85
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * KeyByOperator — override the implicit partition-key with a user-derived
3
+ * key for downstream stateful operators (windowTumbling/reduce/aggregate).
4
+ *
5
+ * The default key is the source partition_id, which gives per-partition
6
+ * state isolation with zero cross-worker contention. .keyBy(...) overrides
7
+ * to a different value per record. The Runner will warn at start time if
8
+ * the chain's logical key consistently differs from the partition key,
9
+ * because that means multiple workers may write the same logical key from
10
+ * different partition leases — i.e. cross-worker contention on
11
+ * (query_id, partition_id, key) state rows.
12
+ */
13
+ export class KeyByOperator {
14
+ constructor(fn) {
15
+ this.kind = 'keyBy'
16
+ this.fn = fn
17
+ }
18
+
19
+ async apply(envelope) {
20
+ const key = String(await this.fn(envelope.msg))
21
+ return [{ ...envelope, key }]
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MapOperator — transform each input record. Sync or async.
3
+ *
4
+ * Operates on the runtime "envelope" object:
5
+ * { msg, key, value, ctx? }
6
+ *
7
+ * User-fn signature: `(msg, ctx?) => newValue`
8
+ *
9
+ * Pre-reducer:
10
+ * msg = the original Queen message (with .data, .partitionId, ...)
11
+ * ctx = undefined (Queen msg already carries the metadata)
12
+ *
13
+ * Post-reducer (after .windowTumbling + .reduce/.aggregate):
14
+ * msg = the aggregated value (e.g. { count, sum, min, max })
15
+ * ctx = { partition, partitionId, key, windowKey, windowStart, windowEnd }
16
+ */
17
+ export class MapOperator {
18
+ constructor(fn) {
19
+ this.kind = 'map'
20
+ this.fn = fn
21
+ }
22
+
23
+ async apply(envelope) {
24
+ const value = await this.fn(envelope.msg, envelope.ctx)
25
+ return [{ ...envelope, value }]
26
+ }
27
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * ReduceOperator — fold envelopes within a window into a single value.
3
+ *
4
+ * Operates over a *batch* of envelopes (called once per cycle, after
5
+ * stateless ops + window annotation). Returns:
6
+ * - state mutations (upserts for still-open windows, deletes for closed)
7
+ * - emit values (closed windows' final accumulator) for sink/foreach
8
+ *
9
+ * The reducer expects each envelope to carry `key`, `windowStart`,
10
+ * `windowEnd`, and `windowKey` (from upstream KeyBy + WindowTumbling).
11
+ *
12
+ * Window-close semantics: a window is treated as closed when its
13
+ * windowEnd <= maxWindowEnd in this cycle's batch. Any window equal to
14
+ * maxWindowEnd is still open (more events may arrive on the next cycle).
15
+ */
16
+ export class ReduceOperator {
17
+ constructor(fn, initial) {
18
+ this.kind = 'reduce'
19
+ this.fn = fn
20
+ this.initial = initial
21
+ this.config = { hasInitial: initial !== undefined }
22
+ }
23
+
24
+ /**
25
+ * Run the reducer over a batch of envelopes that all share the same
26
+ * source partition_id (the cycle's scope).
27
+ *
28
+ * @param {{
29
+ * envelopes: Array<{
30
+ * key:string, windowStart:number, windowEnd:number, windowKey:string,
31
+ * value:any, operatorTag?:string, gracePeriodMs?:number
32
+ * }>,
33
+ * loadedState: Map<string, any>, // state-key -> stored accumulator
34
+ * streamTimeMs?: number, // partition clock (max createdAt of batch)
35
+ * operatorTag?: string, // window operator's structural tag
36
+ * gracePeriodMs?: number, // accept events for windowEnd + grace
37
+ * ignoreUnowned?: boolean // skip seeded rows whose tag doesn't match
38
+ * }} ctx
39
+ * @returns {Promise<{
40
+ * stateOps: Array<{type:'upsert'|'delete',key:string,value?:any}>,
41
+ * emits: Array<{key:string, windowStart:number, windowKey:string, value:any}>
42
+ * }>}
43
+ *
44
+ * Window-close semantics
45
+ * ----------------------
46
+ * A window is closed when the partition's stream time has advanced past
47
+ * its windowEnd plus the grace period:
48
+ *
49
+ * closed = (windowEnd + gracePeriodMs) <= effectiveStreamTime
50
+ *
51
+ * The caller seeds `loadedState` with ALL open windows for this partition
52
+ * (Runner loads via /streams/v1/state/get with keys=[]) so windows which
53
+ * received no traffic in this batch can still be detected as closed and
54
+ * flushed.
55
+ *
56
+ * State key shape (v0.2+): `${operatorTag}\u001f${windowKey}\u001f${userKey}`.
57
+ * Seeded state rows whose operatorTag doesn't match this reducer's are
58
+ * left untouched (they belong to a different window operator in the same
59
+ * partition shard, or are reserved internal keys like `__wm__`).
60
+ */
61
+ async run({ envelopes, loadedState, streamTimeMs, operatorTag = '', gracePeriodMs = 0, ignoreUnowned = true }) {
62
+ // accumulators[stateKey] = { key, windowStart, windowEnd, windowKey, acc, touched, seeded }
63
+ const accumulators = new Map()
64
+
65
+ // Seed accumulators with every loaded state row owned by THIS operator.
66
+ // Rows owned by other operators (or internal keys like __wm__) are
67
+ // skipped — they're not ours to close or upsert.
68
+ for (const [stateKey, value] of loadedState.entries()) {
69
+ const parts = parseStateKey(stateKey)
70
+ if (!parts) continue
71
+ // Skip reserved internal keys.
72
+ if (parts.operatorTag.startsWith('__')) continue
73
+ // Skip rows owned by a different operator.
74
+ if (ignoreUnowned && operatorTag && parts.operatorTag !== operatorTag) continue
75
+
76
+ const ws = value && typeof value.windowStart === 'number'
77
+ ? value.windowStart
78
+ : Date.parse(parts.windowKey) || 0
79
+ const we = value && typeof value.windowEnd === 'number'
80
+ ? value.windowEnd
81
+ : ws
82
+ accumulators.set(stateKey, {
83
+ key: parts.userKey,
84
+ windowStart: ws,
85
+ windowEnd: we,
86
+ windowKey: parts.windowKey,
87
+ acc: value && value.acc !== undefined ? value.acc : value,
88
+ touched: false,
89
+ seeded: true
90
+ })
91
+ }
92
+
93
+ // Apply this batch's envelopes onto matching accumulators.
94
+ let batchMaxWindowStart = -Infinity
95
+ for (const env of envelopes) {
96
+ const tag = env.operatorTag || operatorTag || ''
97
+ const stateKey = stateKeyFor(tag, env.windowKey, env.key)
98
+ let entry = accumulators.get(stateKey)
99
+ if (!entry) {
100
+ entry = {
101
+ key: env.key,
102
+ windowStart: env.windowStart,
103
+ windowEnd: env.windowEnd,
104
+ windowKey: env.windowKey,
105
+ acc: this._initial(),
106
+ touched: false,
107
+ seeded: false
108
+ }
109
+ accumulators.set(stateKey, entry)
110
+ }
111
+ entry.acc = await this.fn(entry.acc, env.value !== undefined ? env.value : env.msg)
112
+ entry.touched = true
113
+ if (env.windowStart > batchMaxWindowStart) batchMaxWindowStart = env.windowStart
114
+ }
115
+
116
+ // Stream time = caller-provided clock OR the latest batch window's
117
+ // start as a conservative fallback (keeps the latest window open).
118
+ const effectiveStreamTime = typeof streamTimeMs === 'number' && streamTimeMs > -Infinity
119
+ ? streamTimeMs
120
+ : batchMaxWindowStart
121
+
122
+ const stateOps = []
123
+ const emits = []
124
+ for (const [stateKey, entry] of accumulators.entries()) {
125
+ const closed = (entry.windowEnd + gracePeriodMs) <= effectiveStreamTime
126
+ if (closed) {
127
+ emits.push({
128
+ key: entry.key,
129
+ windowStart: entry.windowStart,
130
+ windowEnd: entry.windowEnd,
131
+ windowKey: entry.windowKey,
132
+ value: entry.acc
133
+ })
134
+ if (entry.seeded) {
135
+ stateOps.push({ type: 'delete', key: stateKey })
136
+ }
137
+ } else if (entry.touched) {
138
+ stateOps.push({
139
+ type: 'upsert',
140
+ key: stateKey,
141
+ value: { acc: entry.acc, windowStart: entry.windowStart, windowEnd: entry.windowEnd }
142
+ })
143
+ }
144
+ }
145
+ return { stateOps, emits }
146
+ }
147
+
148
+ _initial() {
149
+ if (this.initial === undefined) return undefined
150
+ // Deep clone so each window starts fresh
151
+ if (this.initial === null || typeof this.initial !== 'object') return this.initial
152
+ return JSON.parse(JSON.stringify(this.initial))
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Build a state key for a windowed reducer.
158
+ *
159
+ * Shape (v0.2+):
160
+ * `${operatorTag}\u001f${windowKey}\u001f${userKey}`
161
+ *
162
+ * The `operatorTag` (e.g. "tumb:60", "slide:60:10", "sess:30", "cron:minute")
163
+ * lets multiple window operators within one query coexist in the same
164
+ * `queen_streams.state` table without colliding, and lets the idle-flush
165
+ * scan filter to a single operator's keys via prefix LIKE matching.
166
+ *
167
+ * Reserved key prefix: state keys starting with "__" are internal (e.g.
168
+ * "__wm__" for per-partition watermarks). User code should never write
169
+ * keys starting with "__".
170
+ */
171
+ export function stateKeyFor(operatorTag, windowKey, userKey) {
172
+ // Backwards-compat shim: if called with two arguments (the v0.1 shape),
173
+ // route through with a synthetic "legacy" tag so existing tests keep
174
+ // working until they explicitly pass an operatorTag.
175
+ if (userKey === undefined) {
176
+ return `${operatorTag}\u001f${windowKey}`
177
+ }
178
+ return `${operatorTag}\u001f${windowKey}\u001f${userKey}`
179
+ }
180
+
181
+ /**
182
+ * Decompose a state key into its parts. Returns null if the key doesn't
183
+ * match the expected shape.
184
+ */
185
+ export function parseStateKey(stateKey) {
186
+ if (typeof stateKey !== 'string') return null
187
+ const parts = stateKey.split('\u001f')
188
+ if (parts.length === 3) {
189
+ return { operatorTag: parts[0], windowKey: parts[1], userKey: parts[2] }
190
+ }
191
+ if (parts.length === 2) {
192
+ // Legacy v0.1 shape: ${windowKey}\u001f${userKey}
193
+ return { operatorTag: '', windowKey: parts[0], userKey: parts[1] }
194
+ }
195
+ return null
196
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SinkOperator — terminal `.to(queue)`. Records flowing into the sink are
3
+ * pushed onto the destination Queen queue as part of the cycle commit.
4
+ *
5
+ * The runtime is responsible for shaping each sink push item:
6
+ * { queue, partition?, payload }
7
+ *
8
+ * If the sink has a partition function (opts.partition), it's called with
9
+ * the value to derive the partition name; otherwise the source partition
10
+ * name is reused (preserves entity-level co-partitioning by default).
11
+ */
12
+ export class SinkOperator {
13
+ constructor(queueBuilder, opts = {}) {
14
+ this.kind = 'sink'
15
+ this.queueBuilder = queueBuilder
16
+ this.queueName = extractQueueName(queueBuilder)
17
+ this.partitionResolver = opts.partition
18
+ this.config = { kind: 'sink', queue: this.queueName }
19
+ }
20
+
21
+ /**
22
+ * Build sink push items from a list of {key, value, sourceMsg} entries.
23
+ * Called by the Runner after upstream operators have produced final values.
24
+ */
25
+ buildPushItems(entries, sourcePartitionName) {
26
+ return entries.map(({ value, sourceMsg }) => ({
27
+ queue: this.queueName,
28
+ partition: this._resolvePartition(value, sourcePartitionName),
29
+ payload: value === null || typeof value !== 'object' ? { value } : value
30
+ }))
31
+ }
32
+
33
+ _resolvePartition(value, sourcePartitionName) {
34
+ if (typeof this.partitionResolver === 'function') {
35
+ return String(this.partitionResolver(value))
36
+ }
37
+ if (typeof this.partitionResolver === 'string') {
38
+ return this.partitionResolver
39
+ }
40
+ return sourcePartitionName || 'Default'
41
+ }
42
+ }
43
+
44
+ function extractQueueName(qb) {
45
+ // QueueBuilder.name (getter) is the canonical accessor since v0.14.x.
46
+ // Test fakes and raw objects fall back to _queueName / queueName fields.
47
+ if (qb && typeof qb.name === 'string' && qb.name.length > 0) return qb.name
48
+ if (qb && typeof qb._queueName === 'string') return qb._queueName
49
+ if (qb && typeof qb.queueName === 'string') return qb.queueName
50
+ throw new Error(
51
+ 'Could not extract queue name from sink target. ' +
52
+ 'Pass either a queen.queue("name") builder or { queueName: "name" }.'
53
+ )
54
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * WindowCronOperator — wall-clock-aligned tumbling windows.
3
+ *
4
+ * Bucket events by absolute UTC boundaries (minute-on-the-minute,
5
+ * hour-on-the-hour, day at 00:00 UTC, week starting Mon 00:00 UTC).
6
+ * Equivalent to WindowTumblingOperator with a fixed size, but the boundary
7
+ * is anchored to wall-clock instead of relative-to-epoch arithmetic.
8
+ *
9
+ * v0.2 ships only the `every:` shorthand; full cron expressions
10
+ * (`'*\/5 * * * *'`) are reserved for v0.3 and would add a dep.
11
+ *
12
+ * Config:
13
+ * every (str) 'second'|'minute'|'hour'|'day'|'week' (required)
14
+ * gracePeriod (sec) accept events for windowEnd + grace before closing
15
+ * idleFlushMs (ms) ripe-window flush cadence; default 30_000
16
+ * eventTime, allowedLateness, onLate — same as the other window operators
17
+ */
18
+
19
+ import { extractEventTime, normaliseLateOpts, everyToMs, MS_PER_SEC } from './_windowCommon.js'
20
+
21
+ const WEEK_EPOCH_OFFSET_MS = 4 * 86_400 * 1000 // Jan 1 1970 UTC was Thursday;
22
+ // shift so weeks align to Monday 00:00 UTC
23
+
24
+ export class WindowCronOperator {
25
+ constructor({
26
+ every,
27
+ gracePeriod = 0,
28
+ idleFlushMs = 30_000,
29
+ eventTime,
30
+ allowedLateness = 0,
31
+ onLate
32
+ } = {}) {
33
+ if (typeof every !== 'string') {
34
+ throw new Error("windowCron requires { every: 'second'|'minute'|'hour'|'day'|'week' }")
35
+ }
36
+ this.kind = 'window'
37
+ this.windowKind = 'cron'
38
+ this.every = every
39
+ this.windowMs = everyToMs(every)
40
+ this.gracePeriodMs = Math.max(0, Math.floor(gracePeriod * MS_PER_SEC))
41
+ this.idleFlushMs = Math.max(0, Math.floor(idleFlushMs))
42
+ this.eventTimeFn = typeof eventTime === 'function' ? eventTime : null
43
+ this.allowedLatenessMs = Math.max(0, Math.floor(allowedLateness * MS_PER_SEC))
44
+ this.onLatePolicy = normaliseLateOpts(onLate)
45
+ this.operatorTag = `cron:${every}`
46
+ this.config = {
47
+ kind: 'window-cron',
48
+ every,
49
+ gracePeriod,
50
+ idleFlushMs: this.idleFlushMs,
51
+ eventTime: !!this.eventTimeFn,
52
+ allowedLateness,
53
+ onLate: this.onLatePolicy
54
+ }
55
+ }
56
+
57
+ async apply(envelope) {
58
+ const ts = extractEventTime(envelope.msg, this.eventTimeFn)
59
+ if (ts === null) {
60
+ throw new Error('windowCron could not determine timestamp for message')
61
+ }
62
+ const windowStart = this._floorToBoundary(ts)
63
+ return [{
64
+ ...envelope,
65
+ eventTimeMs: ts,
66
+ windowStart,
67
+ windowEnd: windowStart + this.windowMs,
68
+ windowKey: new Date(windowStart).toISOString(),
69
+ operatorTag: this.operatorTag,
70
+ gracePeriodMs: this.gracePeriodMs
71
+ }]
72
+ }
73
+
74
+ _floorToBoundary(ts) {
75
+ if (this.every === 'week') {
76
+ // Align to Monday 00:00 UTC.
77
+ const shifted = ts - WEEK_EPOCH_OFFSET_MS
78
+ const week = Math.floor(shifted / this.windowMs)
79
+ return week * this.windowMs + WEEK_EPOCH_OFFSET_MS
80
+ }
81
+ return Math.floor(ts / this.windowMs) * this.windowMs
82
+ }
83
+ }