@worker-protocol/cloudflare 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ to watch in a first deployment.
34
34
  | `withSubscriptions` | SUB-7's subscriptions, found and ensured in one step | **one** object, never one per shard |
35
35
  | `withOutbox` | an outbox, drained when a call ends and retried by the alarm | every object whose changes raise events |
36
36
  | `withLogs` | LOG-2's window, filtered and paged in SQL | the object `/logs` reads, and a Tail Worker writes |
37
+ | `withLifecycle` | EVT-15's births and endings of Tasks and Alerts, found by comparing snapshots | over `withOutbox`, in the object whose Facts the Tasks and Alerts derive from |
37
38
 
38
39
  Mixins rather than one base class, because a Worker in production keeps one object per vehicle and
39
40
  one for the fleet: the subscriptions belong in the one, an outbox in every other, and a class that
@@ -155,12 +156,17 @@ export default {
155
156
  };
156
157
  ```
157
158
 
158
- The consumer of the events Queue publishes each batch through the hub — the subscriptions are read
159
- once per type for the whole batch — and leaves one delivery per matching subscription on the
160
- deliveries Queue, then hands each event to the broker. An event of a type the Worker does not
161
- declare is set aside rather than retried, since no retry would mend it, and a failure to reach the
162
- store retries the batch whole, once. The consumer of the deliveries Queue runs `deliver()` and hands
163
- its decision back: `retry({ delaySeconds })`, or `ack()`.
159
+ The consumer of the events Queue routes each batch through the hub — the subscriptions are read once
160
+ per type for the whole batch — and **makes the first attempt at every delivery itself**, reading
161
+ each subscription once, then hands each event to the broker. Only what the hub asks to retry goes to
162
+ the deliveries Queue, with the delay it asked for: each Queue costs a batch wait, and an event that
163
+ crossed both before its first attempt arrived seconds after the Fact that caused it. A sink slower
164
+ than the hub's `attemptTimeoutMs` (ten seconds by default) holds the batch that long and is retried
165
+ like any failure. An event the Worker's own declaration refuses — an undeclared type (EVT-12) or
166
+ an undeclared extension (EVT-18) — is set aside rather than retried, since no retry would mend it,
167
+ and a failure to reach the store retries the batch whole, once. The consumer of the deliveries
168
+ Queue runs the later attempts and hands each decision back: `retry({ delaySeconds })`, or `ack()`,
169
+ backing off from the larger of the attempt the delivery carries and the one the Queue counted.
164
170
 
165
171
  What is given up goes to the dead-letter queue as a `GivenUp`, tagged by its `kind` — a delivery
166
172
  refused for good, outside EVT-8's window or abandoned with its subscription, with the reason; or an
@@ -169,6 +175,42 @@ through `/logs`. Name the same queue as each consumer's `dead_letter_queue` in
169
175
  `wrangler.jsonc`, so it also receives what the platform drops after `max_retries`, and set
170
176
  `max_retries` well above what the hub asks for: its default of 3 cuts a delivery short.
171
177
 
178
+ ## The lifecycle of Tasks and Alerts
179
+
180
+ ```ts
181
+ export class Intake extends withLifecycle(withOutbox(DurableObject<Env>, { events: (env) => env.EVENTS })) {
182
+ snapshot(at: number) {
183
+ return { tasks: openTasks(at, this.facts()), alerts: alerts(at, this.facts()) };
184
+ }
185
+ nextChange(after: number) {
186
+ return nextMinute(after); // or the next deadline, the end of an incident
187
+ }
188
+ async pause(source: string, now: number) {
189
+ this.changing(now, () => this.write(source, now)); // owes what the write changed
190
+ await this.flush();
191
+ }
192
+ }
193
+ ```
194
+
195
+ A Worker that derives its Tasks and Alerts from Facts supplies the two methods above, and
196
+ `withLifecycle` owes their births and endings (EVT-15). It compares at every instant `nextChange`
197
+ names since the last one compared — the mark survives the object, so a missed run leaves no gap, and
198
+ one further behind than `catchUpMs` gives the rest up and calls `lifecycleSkipped` — and an Action's
199
+ `changing(now, write)` compares the instant before its write and after it, in the same transaction.
200
+ Every id is derived from the transition, the resource and its `since`, and the ids owed are kept for
201
+ `keepAnnouncedMs`, so a change seen twice is owed once; an ending whose birth was never announced
202
+ brings its birth with it. `heartbeat()` compares up to now and points the shared alarm at the next
203
+ instant; a cron that calls it starts the chain and restarts it if an alarm is lost.
204
+
205
+ **What a mixin does internally is a function of its module, never a method.** `private` is
206
+ TypeScript's alone: at run time it is a method on the prototype, and a Worker whose class declares
207
+ one of the same name replaces it silently. Only the methods each mixin's interface lists are on the
208
+ object.
209
+
210
+ `withSubscriptions` also answers `wantedHere(source, events)`: which events a live subscription it
211
+ holds would receive, synchronously, so an object that keeps an outbox and the subscriptions leaves
212
+ the rest out inside the transaction of the write that raised them.
213
+
172
214
  ## Also exported
173
215
 
174
216
  `durableOutcomes`, `durableSubscriptions` and `durableLogs` turn a stub into the store `mount()`
package/dist/index.d.ts CHANGED
@@ -9,9 +9,12 @@
9
9
  *
10
10
  * **One mixin per piece**, because a Worker in production keeps one object per vehicle and one for
11
11
  * the fleet, and each object should carry only what it holds: the subscriptions in the one, an
12
- * outbox in every other. A Worker with a single object composes all four in it.
12
+ * outbox in every other. A Worker with a single object composes all of them in it; one that
13
+ * publishes the lifecycle of its Tasks and Alerts from snapshots adds `withLifecycle` over its
14
+ * outbox.
13
15
  */
14
16
  export type { DurableObjectClass, EnvOf, Mixed } from "./durable.ts";
17
+ export { type Advanced, type LifecycleMethods, type LifecycleSnapshot, lifecycleEventsBetween, withLifecycle, } from "./lifecycle.ts";
15
18
  export { durableLogs, type LogMethods, type LogRow, type LogRows, type LogsQuery, type LogsRpc, tailRecords, withLogs, } from "./logs.ts";
16
19
  export { type Flushed, type OutboxEvent, type OutboxMethods, withOutbox } from "./outbox.ts";
17
20
  export { durableOutcomes, type OutcomeMethods, type OutcomesRpc, withOutcomes, } from "./outcomes.ts";
package/dist/index.js CHANGED
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * **One mixin per piece**, because a Worker in production keeps one object per vehicle and one for
11
11
  * the fleet, and each object should carry only what it holds: the subscriptions in the one, an
12
- * outbox in every other. A Worker with a single object composes all four in it.
12
+ * outbox in every other. A Worker with a single object composes all of them in it; one that
13
+ * publishes the lifecycle of its Tasks and Alerts from snapshots adds `withLifecycle` over its
14
+ * outbox.
13
15
  */
16
+ export { lifecycleEventsBetween, withLifecycle, } from "./lifecycle.js";
14
17
  export { durableLogs, tailRecords, withLogs, } from "./logs.js";
15
18
  export { withOutbox } from "./outbox.js";
16
19
  export { durableOutcomes, withOutcomes, } from "./outcomes.js";
@@ -0,0 +1,77 @@
1
+ import { type Alert, type OpenTask } from "@worker-protocol/hono";
2
+ import { type DurableObjectClass, type Mixed } from "./durable.ts";
3
+ import type { OutboxEvent, OutboxMethods } from "./outbox.ts";
4
+ /**
5
+ * When a Task or an Alert is born and when it ends (EVT-15), owed from a Durable Object that derives
6
+ * both from its own Facts.
7
+ *
8
+ * **There is one way of finding out, and the Worker supplies two things to it.** `snapshot(at)` is
9
+ * the Tasks and Alerts whose conditions hold at an instant, and `nextChange(after)` is the next
10
+ * instant at which one can begin or end without anybody acting — a minute boundary, the end of an
11
+ * incident, a deadline. Everything else is here, because every Worker that publishes its lifecycle
12
+ * from snapshots wrote it again:
13
+ *
14
+ * - **Time, which nobody witnesses,** is compared at every instant `nextChange` names between the
15
+ * last one compared and now. The mark of the last instant survives the object, so a run that was
16
+ * missed leaves no gap; one that fell further behind than `catchUpMs` gives the rest up and says
17
+ * so, and one run compares at most `mostPerRunMs`, so a catch-up is spread over several.
18
+ * - **An Action** compares the instant it acts at before its write and after it, in the same
19
+ * transaction (`changing`), and owes what changed in the same call — the moment only it knows.
20
+ * - **Every id is derived, never drawn:** the transition, the resource's id and its `since`. So an
21
+ * instant compared twice, an Action and the heartbeat seeing the same change, or an alarm
22
+ * delivered again all name the same event, and the ids already owed are remembered for
23
+ * `keepAnnouncedMs` so that none is owed twice.
24
+ * - **Nothing announces an end without its beginning.** A Task an Action ended before the heartbeat
25
+ * compared the instant it was born at was never announced, so its birth is owed with its end.
26
+ * - **The heartbeat keeps itself going** on the one alarm `withOutbox` shares, at the next instant
27
+ * `nextChange` names. A cron that calls `heartbeat()` every so often is what starts the chain and
28
+ * restarts it if an alarm is ever lost.
29
+ *
30
+ * It composes over `withOutbox`, whose `enqueue` it owes into and whose alarm it shares.
31
+ */
32
+ /** The Tasks and Alerts whose conditions hold at one instant. */
33
+ export type LifecycleSnapshot = {
34
+ tasks: OpenTask[];
35
+ alerts: Alert[];
36
+ };
37
+ /** What one comparison run did: the instants it covered, what it owed, and what it gave up. */
38
+ export type Advanced = {
39
+ from: number;
40
+ to: number;
41
+ owed: number;
42
+ /** The instants it did not compare because it had fallen further behind than `catchUpMs`. */
43
+ skipped?: {
44
+ from: number;
45
+ to: number;
46
+ };
47
+ };
48
+ /** What `withLifecycle` adds to a Durable Object, and the two methods it asks of it. */
49
+ export interface LifecycleMethods {
50
+ /** The Tasks and Alerts whose conditions hold at `at`, read synchronously from this object. */
51
+ snapshot(at: number): LifecycleSnapshot;
52
+ /** The first instant after `after` at which one can begin or end without anybody acting. */
53
+ nextChange(after: number): number;
54
+ changing<T>(now: number, write: () => T): T;
55
+ advance(now: number): Promise<Advanced>;
56
+ heartbeat(): Promise<Advanced & {
57
+ next: number;
58
+ }>;
59
+ lifecycleSkipped(range: {
60
+ from: number;
61
+ to: number;
62
+ }): void;
63
+ }
64
+ /** What was born and what ended between two snapshots; an ending is dated `at`, after its birth. */
65
+ export declare function lifecycleEventsBetween(given: {
66
+ previous: LifecycleSnapshot;
67
+ current: LifecycleSnapshot;
68
+ at: number;
69
+ }): OutboxEvent[];
70
+ export declare function withLifecycle<B extends Mixed<DurableObjectClass, OutboxMethods>>(Base: B, options?: {
71
+ /** How far behind the comparison may fall before the instants in between are given up. */
72
+ catchUpMs?: number;
73
+ /** How much one run compares at most, so a catch-up is spread over several. */
74
+ mostPerRunMs?: number;
75
+ /** How long an owed id is remembered: well past any instant that could be compared again. */
76
+ keepAnnouncedMs?: number;
77
+ }): Mixed<B, LifecycleMethods>;
@@ -0,0 +1,158 @@
1
+ import { alertEnded, alertRaised, lifecycleChanges, taskEnded, taskRaised, } from "@worker-protocol/hono";
2
+ import { first } from "./durable.js";
3
+ import { migrate } from "./schema.js";
4
+ const MINUTE = 60_000;
5
+ /** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
6
+ const SCHEMA = {
7
+ piece: "worker-protocol.lifecycle",
8
+ steps: [
9
+ [
10
+ // Every lifecycle event ever owed, by id, so none is owed twice.
11
+ `CREATE TABLE IF NOT EXISTS wp_lifecycle_owed (
12
+ id TEXT PRIMARY KEY,
13
+ at INTEGER NOT NULL
14
+ )`,
15
+ "CREATE INDEX IF NOT EXISTS wp_lifecycle_owed_at ON wp_lifecycle_owed (at)",
16
+ // The last instant compared, one row.
17
+ `CREATE TABLE IF NOT EXISTS wp_lifecycle_mark (
18
+ one INTEGER PRIMARY KEY CHECK (one = 1),
19
+ at INTEGER NOT NULL
20
+ )`,
21
+ ],
22
+ ],
23
+ };
24
+ const idOf = (transition, resource) => `${transition}:${resource.id}:${resource.since.getTime()}`;
25
+ /** A birth carries the `since` the resource declares (TASK-28, ALRT-3) as its `time`. */
26
+ const bornTask = (task) => ({
27
+ ...taskRaised(task),
28
+ id: idOf("task-raised", task),
29
+ time: task.since.getTime(),
30
+ });
31
+ const bornAlert = (alert) => ({
32
+ ...alertRaised(alert),
33
+ id: idOf("alert-raised", alert),
34
+ time: alert.since.getTime(),
35
+ });
36
+ /** What was born and what ended between two snapshots; an ending is dated `at`, after its birth. */
37
+ export function lifecycleEventsBetween(given) {
38
+ const { previous, current, at } = given;
39
+ const tasks = lifecycleChanges({ previous: previous.tasks, current: current.tasks });
40
+ const alerts = lifecycleChanges({ previous: previous.alerts, current: current.alerts });
41
+ return [
42
+ ...tasks.raised.map(bornTask),
43
+ ...tasks.ended.flatMap((task) => [
44
+ bornTask(task),
45
+ { ...taskEnded(task), id: idOf("task-ended", task), time: at },
46
+ ]),
47
+ ...alerts.raised.map(bornAlert),
48
+ ...alerts.ended.flatMap((alert) => [
49
+ bornAlert(alert),
50
+ { ...alertEnded(alert), id: idOf("alert-ended", alert), time: at },
51
+ ]),
52
+ ];
53
+ }
54
+ /**
55
+ * Owes each event not owed before, in the transaction the caller is in, and answers how many were
56
+ * new. The outbox holds an id back only while it waits; this remembers it after.
57
+ *
58
+ * **A function of the module and not a method of the mixin, and that is the rule for every mixin
59
+ * here.** `private` is TypeScript's alone: at run time it is a method on the prototype like any
60
+ * other, and `Mixed` does not carry it, so a Worker whose class declares a method of the same name
61
+ * replaces it without a word from the compiler or the runtime. Only what a mixin publishes as its
62
+ * interface is a method; what it does internally is reached by no subclass.
63
+ */
64
+ function owe(given) {
65
+ const { storage, enqueue, now, events } = given;
66
+ const sql = migrate(storage, SCHEMA);
67
+ // `RETURNING` answers a row only for an id this inserted, and nothing for one already there.
68
+ const fresh = events.filter((event) => sql
69
+ .exec("INSERT INTO wp_lifecycle_owed (id, at) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING id", event.id, now)
70
+ .toArray().length > 0);
71
+ enqueue(now, fresh);
72
+ return fresh.length;
73
+ }
74
+ export function withLifecycle(Base, options = {}) {
75
+ const catchUpMs = options.catchUpMs ?? 120 * MINUTE;
76
+ const mostPerRunMs = options.mostPerRunMs ?? 60 * MINUTE;
77
+ const keepAnnouncedMs = options.keepAnnouncedMs ?? 2 * 24 * 60 * MINUTE;
78
+ class WithLifecycle extends Base {
79
+ /**
80
+ * Performs `write` and owes what it changed: the Tasks and Alerts at `now` before it and after
81
+ * it, compared in the same transaction as the write. `write` is synchronous, because the
82
+ * comparison is only true of the write if nothing interleaves between the three.
83
+ */
84
+ changing(now, write) {
85
+ const before = this.snapshot(now);
86
+ const done = write();
87
+ owe({
88
+ storage: this.ctx.storage,
89
+ enqueue: (at, events) => this.enqueue(at, events),
90
+ now,
91
+ events: lifecycleEventsBetween({ previous: before, current: this.snapshot(now), at: now }),
92
+ });
93
+ return done;
94
+ }
95
+ /**
96
+ * Compares from the last instant compared up to `now`, at every instant `nextChange` names,
97
+ * owes what was born and what ended, and sends it. The first run only sets the mark, because
98
+ * what was born before anybody watched was not seen being born.
99
+ */
100
+ async advance(now) {
101
+ const sql = migrate(this.ctx.storage, SCHEMA);
102
+ const mark = first(sql.exec("SELECT at FROM wp_lifecycle_mark WHERE one = 1"))?.at;
103
+ let from = mark ?? now;
104
+ let skipped;
105
+ if (now - from > catchUpMs) {
106
+ skipped = { from, to: now - catchUpMs };
107
+ from = now - catchUpMs;
108
+ this.lifecycleSkipped(skipped);
109
+ }
110
+ const to = Math.min(now, from + mostPerRunMs);
111
+ const owed = [];
112
+ if (to > from) {
113
+ let previous = this.snapshot(from);
114
+ let at = from;
115
+ while (at < to) {
116
+ const asked = this.nextChange(at);
117
+ // An answer that does not move forward is read as nothing before `to`.
118
+ const next = asked > at ? Math.min(asked, to) : to;
119
+ const current = this.snapshot(next);
120
+ owed.push(...lifecycleEventsBetween({ previous, current, at: next }));
121
+ previous = current;
122
+ at = next;
123
+ }
124
+ }
125
+ const fresh = owe({
126
+ storage: this.ctx.storage,
127
+ enqueue: (at, events) => this.enqueue(at, events),
128
+ now,
129
+ events: owed,
130
+ });
131
+ sql.exec("INSERT OR REPLACE INTO wp_lifecycle_mark (one, at) VALUES (1, ?)", Math.max(to, from));
132
+ sql.exec("DELETE FROM wp_lifecycle_owed WHERE at < ?", now - keepAnnouncedMs);
133
+ await this.flush();
134
+ return { from, to, owed: fresh, ...(skipped === undefined ? {} : { skipped }) };
135
+ }
136
+ /**
137
+ * Compares up to now, then asks the one alarm to come back at the next instant anything can
138
+ * change at. A cron calls it to start the chain and to restart it if an alarm was ever lost;
139
+ * after that the alarm keeps it going on its own (`wake`).
140
+ */
141
+ async heartbeat() {
142
+ const advanced = await this.advance(Date.now());
143
+ const next = this.nextChange(Date.now());
144
+ await this.wakeAt(next);
145
+ return { ...advanced, next };
146
+ }
147
+ /**
148
+ * Told when a run gave instants up, for a Worker that keeps records to write one. Nothing,
149
+ * unless overridden.
150
+ */
151
+ lifecycleSkipped(_range) { }
152
+ /** The alarm `heartbeat` asked for. A Worker that needs the alarm too calls `super.wake()`. */
153
+ async wake() {
154
+ await this.heartbeat();
155
+ }
156
+ }
157
+ return WithLifecycle;
158
+ }
package/dist/queues.d.ts CHANGED
@@ -4,12 +4,20 @@ import type { OutboxEvent } from "./outbox.ts";
4
4
  /**
5
5
  * The two Queues between an outbox and a sink, and the one handler that consumes both.
6
6
  *
7
- * **Events, then deliveries.** Every object's `flush()` sends its events to the events Queue. Its
8
- * consumer publishes each batch through the hub — the subscriptions are read once per type for the
9
- * whole batch, from the one object that holds them — and leaves one delivery per matching
10
- * subscription on the deliveries Queue, whose consumer makes the attempt. Fanning out from each
11
- * object instead would make a run that touches thousands of them thousands of calls to that one
12
- * object, and an outage of it would leave every outbox retrying; here the events wait in a Queue.
7
+ * **Events, then the first attempt, then deliveries.** Every object's `flush()` sends its events to
8
+ * the events Queue. Its consumer routes each batch through the hub — the subscriptions are read once
9
+ * per type for the whole batch, from the one object that holds them — and makes the first attempt
10
+ * at every delivery itself, reading each subscription once. Only what the hub asks to retry goes to
11
+ * the deliveries Queue, with the delay it asked for, and that Queue's consumer makes the later
12
+ * attempts. Fanning out from each object instead would make a run that touches thousands of them
13
+ * thousands of calls to that one object, and an outage of it would leave every outbox retrying.
14
+ *
15
+ * **The first attempt is made here because each Queue costs a batch wait.** An event that crossed
16
+ * both Queues before its first attempt paid the wait twice, which put it seconds behind the Fact
17
+ * that caused it. A sink slower than the hub's `attemptTimeoutMs` holds the batch that long and no
18
+ * longer, and is retried from the deliveries Queue like any other failure. If the first attempts
19
+ * cannot be made at all — the subscriptions object is unreachable — the deliveries are queued as
20
+ * they were and nothing is lost.
13
21
  *
14
22
  * **What is given up stays visible.** A delivery `deliver()` gives up on, and an event of a type the
15
23
  * Worker does not declare, go to the dead-letter queue with the reason, to be inspected and never
@@ -26,8 +34,9 @@ type Hub = ReturnType<typeof eventHub>;
26
34
  export declare const deliveryQueue: (queue: Queue<Delivery>) => DeliveryQueue;
27
35
  /**
28
36
  * What the consumer sets aside in the dead-letter queue, tagged by what it is: a delivery given up,
29
- * with why, or an event of a type the Worker does not declare (EVT-12) — a mistake in the Worker's
30
- * own code, which no retry would mend.
37
+ * with why, or an event the Worker's own declaration refuses — a type it does not declare (EVT-12),
38
+ * or an extension its type does not (EVT-18). Both are mistakes in the Worker's own code, which no
39
+ * retry would mend; `detail` says which.
31
40
  */
32
41
  export type GivenUp = {
33
42
  kind: "delivery";
@@ -37,6 +46,7 @@ export type GivenUp = {
37
46
  kind: "event";
38
47
  event: OutboxEvent;
39
48
  reason: "undeclared";
49
+ detail: string;
40
50
  };
41
51
  export type QueuesConfig<E> = {
42
52
  /** The Queue names, as `wrangler.jsonc` gives them: how the handler tells the two batches apart. */
package/dist/queues.js CHANGED
@@ -6,8 +6,11 @@ const backoff = (attempts) => Math.min(10 * 2 ** Math.max(attempts - 1, 0), 3600
6
6
  * hub has several. The chunks are independent — the Queue promises no order — so they go together.
7
7
  */
8
8
  export const deliveryQueue = (queue) => ({
9
- send: async (delivery) => {
10
- await queue.send(delivery, { contentType: "json" });
9
+ send: async (delivery, options) => {
10
+ await queue.send(delivery, {
11
+ contentType: "json",
12
+ ...(options?.delaySeconds === undefined ? {} : { delaySeconds: options.delaySeconds }),
13
+ });
11
14
  },
12
15
  sendBatch: async (deliveries) => {
13
16
  const chunks = [];
@@ -41,35 +44,64 @@ export function consumeQueues(config) {
41
44
  // The record is the courtesy copy; the dead-letter queue is the one that is kept.
42
45
  }
43
46
  };
47
+ /** A delivery given up, set aside with why, and a record of it where the Worker keeps logs. */
48
+ const giveUp = (given) => {
49
+ const { delivery, gaveUp } = given;
50
+ return setAside({
51
+ message: given.message,
52
+ kept: { kind: "delivery", delivery, gaveUp },
53
+ deadLetter: given.deadLetter,
54
+ rows: given.rows,
55
+ row: {
56
+ at: Date.now(),
57
+ level: "warn",
58
+ message: "a delivery was given up",
59
+ fields: {
60
+ subscription: delivery.subscription,
61
+ event: delivery.event.id,
62
+ type: delivery.event.type,
63
+ reason: gaveUp.reason,
64
+ ...(gaveUp.status === null ? {} : { status: gaveUp.status }),
65
+ },
66
+ },
67
+ });
68
+ };
44
69
  async function events(batch, env) {
45
70
  const hub = config.hub(env);
46
71
  const deadLetter = config.deadLetter(env);
47
72
  const rows = [];
48
- // EVT-12, asked first: an event the Worker does not declare can never be published, so it is
49
- // set aside at once rather than failing the batch beside it a hundred times over.
50
- const declared = batch.messages.filter((message) => hub.declares(message.body.type));
51
- const undeclared = batch.messages.filter((message) => !hub.declares(message.body.type));
52
- await Promise.all(undeclared.map((message) => setAside({
53
- message,
54
- kept: { kind: "event", event: message.body, reason: "undeclared" },
55
- deadLetter,
56
- rows,
57
- row: {
58
- at: Date.now(),
59
- level: "error",
60
- message: "an event of a type this Worker does not declare was set aside",
61
- fields: { event: message.body.id, type: message.body.type },
62
- },
63
- })));
73
+ // EVT-12, EVT-18, asked first: an event the Worker's own declaration refuses can never be
74
+ // published, so it is set aside at once rather than failing the batch beside it a hundred
75
+ // times over.
76
+ const declared = batch.messages.filter((message) => hub.fault(message.body) === undefined);
77
+ const refused = batch.messages.filter((message) => hub.fault(message.body) !== undefined);
78
+ await Promise.all(refused.map((message) => {
79
+ const detail = hub.fault(message.body);
80
+ return setAside({
81
+ message,
82
+ kept: { kind: "event", event: message.body, reason: "undeclared", detail },
83
+ deadLetter,
84
+ rows,
85
+ row: {
86
+ at: Date.now(),
87
+ level: "error",
88
+ message: "an event this Worker's declaration refuses was set aside",
89
+ fields: { event: message.body.id, type: message.body.type, detail },
90
+ },
91
+ });
92
+ }));
64
93
  let published;
65
94
  try {
66
- published =
67
- declared.length === 0 ? [] : await hub.publishAll(declared.map((message) => message.body));
95
+ const routed = declared.length === 0
96
+ ? { events: [], deliveries: [] }
97
+ : await hub.route(declared.map((message) => message.body));
98
+ await attemptFirst({ hub, deliveries: routed.deliveries, deadLetter, rows });
99
+ published = routed.events;
68
100
  }
69
101
  catch {
70
102
  // The store or the deliveries Queue could not be reached. The batch is retried whole, once,
71
103
  // rather than event by event against the object that is already struggling; a delivery the
72
- // first attempt did queue is queued again under the same id, which a sink discards.
104
+ // first attempt did make is made again under the same id, which a sink discards.
73
105
  for (const message of declared)
74
106
  message.retry({ delaySeconds: backoff(message.attempts) });
75
107
  }
@@ -89,47 +121,82 @@ export function consumeQueues(config) {
89
121
  }
90
122
  await record(rows, env);
91
123
  }
124
+ /**
125
+ * The first attempt at every delivery, made here rather than after a second Queue. What the hub
126
+ * asks to retry goes to the deliveries Queue with the delay it asked for and the attempt counted;
127
+ * what it gives up is set aside. Where the attempts cannot be made at all, every delivery is
128
+ * queued as it is, so the later path makes the first attempt instead.
129
+ */
130
+ async function attemptFirst(given) {
131
+ const { hub, deliveries, deadLetter, rows } = given;
132
+ if (deliveries.length === 0)
133
+ return;
134
+ let outcomes;
135
+ try {
136
+ outcomes = await hub.deliverAll(deliveries);
137
+ }
138
+ catch {
139
+ await Promise.all(deliveries.map((delivery) => hub.later(delivery)));
140
+ return;
141
+ }
142
+ await Promise.all(deliveries.map(async (delivery, at) => {
143
+ const outcome = outcomes[at];
144
+ if ("retryAfterSeconds" in outcome) {
145
+ await hub.later({ ...delivery, attempt: delivery.attempt + 1 }, outcome.retryAfterSeconds);
146
+ return;
147
+ }
148
+ if (outcome.gaveUp === undefined)
149
+ return;
150
+ // No message of its own to settle: the event it belongs to is acked by the caller.
151
+ const kept = { kind: "delivery", delivery, gaveUp: outcome.gaveUp };
152
+ await deadLetter.send(kept, { contentType: "json" });
153
+ rows.push({
154
+ at: Date.now(),
155
+ level: "warn",
156
+ message: "a delivery was given up",
157
+ fields: {
158
+ subscription: delivery.subscription,
159
+ event: delivery.event.id,
160
+ type: delivery.event.type,
161
+ reason: outcome.gaveUp.reason,
162
+ ...(outcome.gaveUp.status === null ? {} : { status: outcome.gaveUp.status }),
163
+ },
164
+ });
165
+ }));
166
+ }
92
167
  async function deliveries(batch, env) {
93
168
  const hub = config.hub(env);
94
169
  const deadLetter = config.deadLetter(env);
95
170
  const rows = [];
96
- await Promise.all(batch.messages.map(async (message) => {
97
- const delivery = message.body;
98
- try {
99
- // `attempts` is the Queue's own count, and the one `deliver` backs off by.
100
- const outcome = await hub.deliver({ ...delivery, attempt: message.attempts });
101
- if ("retryAfterSeconds" in outcome) {
102
- message.retry({ delaySeconds: outcome.retryAfterSeconds });
103
- return;
104
- }
105
- if (outcome.gaveUp === undefined) {
106
- message.ack();
107
- return;
108
- }
109
- const { reason, status } = outcome.gaveUp;
110
- await setAside({
111
- message,
112
- kept: { kind: "delivery", delivery, gaveUp: outcome.gaveUp },
113
- deadLetter,
114
- rows,
115
- row: {
116
- at: Date.now(),
117
- level: "warn",
118
- message: "a delivery was given up",
119
- fields: {
120
- subscription: delivery.subscription,
121
- event: delivery.event.id,
122
- type: delivery.event.type,
123
- reason,
124
- ...(status === null ? {} : { status }),
125
- },
126
- },
127
- });
128
- }
129
- catch {
130
- // The store could not be reached: nothing was settled.
171
+ // `attempt` is the larger of what the body carries and what the Queue counted: a delivery queued
172
+ // after a first attempt made elsewhere already carries 2, and the Queue starts its own count at
173
+ // 1. Either alone would restart the backoff.
174
+ const attempted = batch.messages.map((message) => ({
175
+ message,
176
+ delivery: { ...message.body, attempt: Math.max(message.body.attempt, message.attempts) },
177
+ }));
178
+ let outcomes;
179
+ try {
180
+ outcomes = await hub.deliverAll(attempted.map((one) => one.delivery));
181
+ }
182
+ catch {
183
+ // The store could not be reached: nothing was settled.
184
+ for (const { message } of attempted) {
131
185
  message.retry({ delaySeconds: backoff(message.attempts) });
132
186
  }
187
+ return;
188
+ }
189
+ await Promise.all(attempted.map(async ({ message, delivery }, at) => {
190
+ const outcome = outcomes[at];
191
+ if ("retryAfterSeconds" in outcome) {
192
+ message.retry({ delaySeconds: outcome.retryAfterSeconds });
193
+ }
194
+ else if (outcome.gaveUp === undefined) {
195
+ message.ack();
196
+ }
197
+ else {
198
+ await giveUp({ message, delivery, gaveUp: outcome.gaveUp, deadLetter, rows });
199
+ }
133
200
  }));
134
201
  await record(rows, env);
135
202
  }
@@ -1,4 +1,4 @@
1
- import type { SubscriptionStore } from "@worker-protocol/hono";
1
+ import { type Publishable, type SubscriptionStore } from "@worker-protocol/hono";
2
2
  import { type DurableObjectClass, type Mixed, type Rpc } from "./durable.ts";
3
3
  /** What `withSubscriptions` adds to a Durable Object. Every subscription crosses as JSON. */
4
4
  export interface SubscriptionMethods {
@@ -8,6 +8,7 @@ export interface SubscriptionMethods {
8
8
  created: boolean;
9
9
  };
10
10
  getSubscription(id: string): string | null;
11
+ subscriptionsById(ids: string[]): string[];
11
12
  subscriptionsOf(caller: string | null): string[];
12
13
  subscriptionsFor(type: string): string[];
13
14
  updateSubscription(id: string, change: {
@@ -15,6 +16,7 @@ export interface SubscriptionMethods {
15
16
  clear: string[];
16
17
  }): void;
17
18
  removeSubscription(id: string): void;
19
+ wantedHere(source: string, events: Publishable[]): boolean[];
18
20
  }
19
21
  export declare function withSubscriptions<B extends DurableObjectClass>(Base: B): Mixed<B, SubscriptionMethods>;
20
22
  /** What `durableSubscriptions` needs of a stub: the methods `withSubscriptions` adds, over RPC. */
@@ -1,3 +1,4 @@
1
+ import { wantedBy, } from "@worker-protocol/hono";
1
2
  import { first } from "./durable.js";
2
3
  import { migrate } from "./schema.js";
3
4
  /**
@@ -57,6 +58,16 @@ export function withSubscriptions(Base) {
57
58
  const row = first(sql.exec("SELECT record FROM wp_subscription WHERE id = ?", id));
58
59
  return row?.record ?? null;
59
60
  }
61
+ /** Several by id in one call, so a batch of deliveries crosses the RPC boundary once. */
62
+ subscriptionsById(ids) {
63
+ if (ids.length === 0)
64
+ return [];
65
+ const sql = migrate(this.ctx.storage, SCHEMA);
66
+ return sql
67
+ .exec("SELECT record FROM wp_subscription WHERE id IN (SELECT value FROM json_each(?))", JSON.stringify(ids))
68
+ .toArray()
69
+ .map((row) => row.record);
70
+ }
60
71
  /** SUB-8: one caller's, ended ones included. `IS` because a caller may be `null`. */
61
72
  subscriptionsOf(caller) {
62
73
  const sql = migrate(this.ctx.storage, SCHEMA);
@@ -93,6 +104,19 @@ export function withSubscriptions(Base) {
93
104
  const sql = migrate(this.ctx.storage, SCHEMA);
94
105
  sql.exec("DELETE FROM wp_subscription WHERE id = ?", id);
95
106
  }
107
+ /**
108
+ * Which of these events a live subscription held here would receive (SUB-13), answered
109
+ * synchronously — so a Worker can leave the rest out of its outbox inside the very transaction
110
+ * of the write that raised them, as `changing()` needs. `source` is the Worker's id, which every
111
+ * event carries (EVT-1); the envelope is the hub's own, so the filters read the same attributes.
112
+ */
113
+ wantedHere(source, events) {
114
+ return wantedBy({
115
+ source,
116
+ subscribed: (type) => this.subscriptionsFor(type).map(parse),
117
+ events,
118
+ });
119
+ }
96
120
  }
97
121
  return WithSubscriptions;
98
122
  }
@@ -107,6 +131,7 @@ export const durableSubscriptions = (stub) => {
107
131
  return { subscription: parse(record), created };
108
132
  },
109
133
  get: async (id) => read(await stub.getSubscription(id)),
134
+ getMany: async (ids) => (await stub.subscriptionsById(ids)).map(parse),
110
135
  list: async (caller) => (await stub.subscriptionsOf(caller)).map(parse),
111
136
  forType: async (type) => (await stub.subscriptionsFor(type)).map(parse),
112
137
  // `undefined` names a member to clear, and JSON drops it — so the cleared ones go by name.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@worker-protocol/cloudflare",
3
- "version": "0.7.0",
4
- "workerProtocolEdition": "0.5",
3
+ "version": "0.8.1",
4
+ "workerProtocolEdition": "0.6",
5
5
  "description": "The protocol's stores in Durable Objects, and the Queues between an outbox and a sink: mixins for ENDP-16, SUB-7, LOG-2 and an outbox, for Workers on Cloudflare",
6
6
  "keywords": [
7
7
  "worker-protocol",
@@ -39,7 +39,7 @@
39
39
  "NOTICE"
40
40
  ],
41
41
  "dependencies": {
42
- "@worker-protocol/hono": "0.7.0"
42
+ "@worker-protocol/hono": "0.8.1"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "hono": "^4.13.7",