pi-roundtable-line 0.15.3 → 0.15.4

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/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.15.4] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - `LineOutboxResultEvent.attempts`: how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so a subscriber no longer reads `line_outbox`.
13
+ - Migration `outbox-requested` adds the nullable `line_outbox.requested_at`, set just before a push's request is made and cleared when the push is claimed again.
14
+
15
+ ### Fixed
16
+
17
+ - A stop no longer gives back an attempt whose request may have reached LINE. A push interrupted by the worker stopping used to be put back with its attempt uncounted even when the request was already out, so the count (and `outbox.maxAttempts`) understated the tries; it is now given back only when the stop came before the request.
18
+
19
+ The core peer range stays `>=0.15.0 <0.16.0`.
20
+
8
21
  ## [0.15.3] - 2026-10-15
9
22
 
10
23
  - Release in lockstep with pi-roundtable 0.15.3; no package-specific behavior changes. The core peer range stays `>=0.15.0 <0.16.0`.
package/README.md CHANGED
@@ -184,7 +184,7 @@ The processor that serves it is the package's own; there is no way to read raw i
184
184
  | `joined` | `notice`: `"sent"`, `"failed"` or `"unconfirmed"`, truthfully (see [The join notice](#the-join-notice)) |
185
185
  | `left` | |
186
186
  | `memberJoined`, `memberLeft` | `userIds` |
187
- | `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`) and `errorCode` (see [Sending](#sending)) |
187
+ | `outboxResult` | `sourceKey`, `originRef`, `status` (`sent`, `dead`, `refused`), `errorCode` and `attempts` (see [Sending](#sending)) |
188
188
 
189
189
  Every event but `outboxResult` has `eventId` (LINE's `webhookEventId`, the same on every redelivery), `sourceKey` and `timestamp`; a push is not a webhook event, so `outboxResult` has none of the three.
190
190
  No event carries a reply token, a quote token or a URL.
@@ -277,7 +277,8 @@ Message objects hold what you send, so they live in `line_outbox.messages` until
277
277
  - **Pushes of at most five objects, per chat and sender.** Objects queued for the same chat and the same sender within `outbox.coalesceMs` (2 seconds, counted from the first object, not from the last) share one push, in the order they were queued; a sixth object starts the next push, and a push that holds five goes at once. Different senders never share a push, and a push is never joined if a later push of the chat exists (that would reorder the chat). The chat's pushes go out strictly in order: a push that is backing off holds the ones behind it (a dead or refused one does not).
278
278
  - **A retry key from the start.** Each push gets a UUID the moment its row exists, before any attempt, and every attempt and every retry after a crash sends it as `X-Line-Retry-Key`. LINE keeps a key for 24 hours (`outbox.retryKeyMs`): a push still unsent then is `dead` with `retry_key_expired` and is never sent under a new key.
279
279
  - **Outcomes.** `2xx` and `409` (LINE already has this key) are `sent`. `429`, `5xx` and no answer back off (5 seconds doubling to 1 hour, at least `Retry-After`) and retry, up to `outbox.maxAttempts` (12), then `dead` with the last code. Any other `4xx` is `dead` at once with a code: `sender_name_refused` or `sender_icon_refused` when LINE names that property of the request, else `http_<status>`; nothing else of LINE's answer is kept. A push the quota gate refuses is `refused` and is never sent. A push for a chat that stopped being allowed is `dead` with `source_not_allowed`.
280
- - **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws.
280
+ - **Results.** The subscriber hears an `outboxResult` for each push once it has ended, once per distinct `origin` the push carries (once with `originRef: null` when none was given), at least once: a result waits while no subscriber is attached and is told again later, up to 12 times, if the subscriber throws. The event carries `attempts`, how many tries the push took, the one that ended it included (1 for a push LINE refused at once, 2 after a `5xx` and then a refusal), so there is no need to read `line_outbox`.
281
+ - **What counts as a try.** An attempt is counted when a push is taken up and is given back only when the worker stops before the request was made; the migration `outbox-requested` adds `line_outbox.requested_at`, set just before the request goes out. A stop after it cannot say LINE did not take the request, so that try stays counted (and it counts towards `outbox.maxAttempts`).
281
282
  - **Crashes.** A push in hand when the process dies has a 2-minute lease; the worker puts it back and sends it again under the same key, which LINE answers with `409` if it had taken the first request.
282
283
  - **Service.** The `line-outbox` service runs the worker and counts as `busy` while a push is in flight, so the shutdown waits for it; a push still in flight when the host stops is put back as it was.
283
284
  - **Nothing sensitive in logs.** The worker logs push ids, attempt numbers, status words and codes; never message objects, sender names, retry keys or tokens.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-line",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "description": "A LINE Messaging API transport for pi-roundtable: a signed webhook that persists events before it answers, a durable inbox worker, and an allowlist of the groups and chats it mirrors",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,7 +40,7 @@
40
40
  "@earendil-works/pi-ai": ">=1.0.0 <2",
41
41
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
42
42
  "@types/bun": "1.4.2",
43
- "pi-roundtable": "0.15.3",
43
+ "pi-roundtable": "0.15.4",
44
44
  "typebox": "1.3.34",
45
45
  "typescript": "7.0.2"
46
46
  }
package/src/messages.ts CHANGED
@@ -115,6 +115,13 @@ export interface LineOutboxResultEvent {
115
115
  * `source_not_allowed`); null when it was sent.
116
116
  */
117
117
  errorCode: string | null;
118
+ /**
119
+ * How many tries the push took, the one that ended it included: 1 for a push LINE refused at once,
120
+ * 2 after a `5xx` and then a refusal. A try counts once its request has been made to LINE, also
121
+ * when a stop cut it short, and once the worker took the push up only to end it without a request
122
+ * (`refused`, `source_not_allowed`); a stop before the request does not count.
123
+ */
124
+ attempts: number;
118
125
  }
119
126
 
120
127
  export type LineEvent =
package/src/migrations.ts CHANGED
@@ -136,9 +136,21 @@ export const OUTBOX_MIGRATION: Migration = {
136
136
  },
137
137
  };
138
138
 
139
+ /**
140
+ * When the request of the push's current attempt was handed to the network. A stop that comes after
141
+ * it cannot say LINE did not take the request, so the attempt stays counted.
142
+ */
143
+ export const OUTBOX_REQUESTED_MIGRATION: Migration = {
144
+ name: "outbox-requested",
145
+ up: async (sql) => {
146
+ await sql`ALTER TABLE line_outbox ADD COLUMN IF NOT EXISTS requested_at TIMESTAMPTZ`;
147
+ },
148
+ };
149
+
139
150
  /** Every migration of the package, in the order they run. */
140
151
  export const MIGRATIONS: Migration[] = [
141
152
  INBOX_MIGRATION,
142
153
  MESSAGES_MIGRATION,
143
154
  OUTBOX_MIGRATION,
155
+ OUTBOX_REQUESTED_MIGRATION,
144
156
  ];
@@ -138,7 +138,7 @@ export class OutboxWorker {
138
138
  ORDER BY id LIMIT 1`) as { id: string | number }[];
139
139
  if (!head) return undefined;
140
140
  const [claimed] = (await tx`UPDATE line_outbox
141
- SET status = 'sending', claimed_at = ${now}, attempts = attempts + 1,
141
+ SET status = 'sending', claimed_at = ${now}, requested_at = NULL, attempts = attempts + 1,
142
142
  first_attempt_at = COALESCE(first_attempt_at, ${now})
143
143
  WHERE id = (SELECT id FROM line_outbox WHERE id = ${head.id} AND status = 'queued' FOR UPDATE SKIP LOCKED)
144
144
  RETURNING id, source_key, sender_name, sender_icon_url, messages, retry_key, attempts,
@@ -214,6 +214,11 @@ export class OutboxWorker {
214
214
  const messages = row.messages.map((message) =>
215
215
  Object.keys(sender).length > 0 ? { ...message, sender } : message,
216
216
  );
217
+ // A stop that comes before this leaves the attempt uncounted; one after it cannot say LINE
218
+ // did not take the request.
219
+ controller.signal.throwIfAborted();
220
+ await this.#sql`UPDATE line_outbox SET requested_at = ${this.#now()}
221
+ WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
217
222
  const pushed = await this.#api.push(
218
223
  source.id,
219
224
  messages,
@@ -245,7 +250,8 @@ export class OutboxWorker {
245
250
  this.#quota.settle(reserved.reservation, sent);
246
251
  }
247
252
  } catch (error) {
248
- // Giving up because the worker stops is the restart's doing: the push goes back as it was.
253
+ // Giving up because the worker stops is the restart's doing: the push goes back as it was,
254
+ // unless its request had already been made.
249
255
  if (this.#stopping && controller.signal.aborted) {
250
256
  await this.#later(row, 0, "worker_stopped", false);
251
257
  return;
@@ -307,7 +313,8 @@ export class OutboxWorker {
307
313
  counted: boolean,
308
314
  ): Promise<void> {
309
315
  await this.#sql`UPDATE line_outbox
310
- SET status = 'queued', claimed_at = NULL, attempts = attempts - ${counted ? 0 : 1},
316
+ SET status = 'queued', claimed_at = NULL,
317
+ attempts = attempts - CASE WHEN ${counted ? 1 : 0}::int = 0 AND requested_at IS NULL THEN 1 ELSE 0 END,
311
318
  next_attempt_at = ${new Date(this.#now().getTime() + delayMs)}, last_error_code = ${code}
312
319
  WHERE id = ${row.id} AND status = 'sending' AND claimed_at = ${row.claimed_at}`;
313
320
  }
@@ -332,8 +339,9 @@ export class OutboxWorker {
332
339
  last_error_code: string | null;
333
340
  origin_refs: (string | null)[];
334
341
  notify_attempts: number;
342
+ attempts: number;
335
343
  }[] = await this
336
- .#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts
344
+ .#sql`SELECT id, source_key, status, last_error_code, origin_refs, notify_attempts, attempts
337
345
  FROM line_outbox
338
346
  WHERE notified_at IS NULL AND status IN ('sent', 'dead', 'refused')
339
347
  AND (notify_after IS NULL OR notify_after <= ${now})
@@ -356,6 +364,7 @@ export class OutboxWorker {
356
364
  originRef,
357
365
  status: row.status,
358
366
  errorCode: row.last_error_code,
367
+ attempts: row.attempts,
359
368
  },
360
369
  controller.signal,
361
370
  );