@ultimat3/notify 29.3.2 → 30.0.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/CLAUDE.md +29 -5
- package/README.md +94 -2
- package/package.json +18 -5
- package/src/apns.ts +145 -0
- package/src/attempt.ts +39 -7
- package/src/channel-push.ts +44 -5
- package/src/errors.ts +48 -1
- package/src/fanout-digest.ts +15 -11
- package/src/fanout.ts +2 -2
- package/src/fcm.ts +211 -0
- package/src/index.ts +36 -1
- package/src/ledger-pg.ts +55 -16
- package/src/ledger.ts +59 -19
- package/src/native-jwt.ts +63 -0
- package/src/native-push.ts +173 -0
- package/src/native-transport.ts +99 -0
- package/src/notifier.ts +2 -1
- package/src/plan.ts +2 -0
- package/src/push-device-pg.ts +126 -0
- package/src/push-device.ts +125 -0
- package/src/push-errors.ts +83 -0
- package/src/push-message.ts +109 -0
- package/src/push-register.ts +167 -0
- package/src/token-cache.ts +57 -0
package/CLAUDE.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Tier 4. May import tiers 0-3. Never sideways, never upward.
|
|
4
4
|
|
|
5
|
-
**Its real imports are `core`, `schema` (tier 0), `time` (tier 1)
|
|
6
|
-
its floor** — one above the highest tier it reaches. It
|
|
7
|
-
`FLOOR_ABOVE` row and needs none. The import that fixes it there is `@ultimat3/jobs`: `notifier()`
|
|
5
|
+
**Its real imports are `core`, `schema` (tier 0), `time`, `i18n` (tier 1), `policy` (tier 2),
|
|
6
|
+
`action` and `jobs` (tier 3), so tier 4 is its floor** — one above the highest tier it reaches. It
|
|
7
|
+
sits AT that floor, so it has no `FLOOR_ABOVE` row and needs none. The import that fixes it there is `@ultimat3/jobs`: `notifier()`
|
|
8
8
|
returns a `job`, which is the whole design.
|
|
9
9
|
|
|
10
10
|
## What it may never import, and why
|
|
@@ -14,7 +14,13 @@ returns a `job`, which is the whole design.
|
|
|
14
14
|
| `@ultimat3/mail` | Same tier (4). A `Mailer` is declared **structurally** in `channel-mail.ts` — one method, no dependency — exactly as `PgExecutor` (`@ultimat3/core`) mirrors `@ultimat3/db`. Moving `notify` to tier 5 to legalise the import would put notifications above `render`, `pwa` and `ui` for one channel's transport, and would then need a `cli → notify` edge the way `cli → scraping` does. |
|
|
15
15
|
| `@ultimat3/render`, `@ultimat3/ui`, `@ultimat3/ai`, `@ultimat3/mcp`, `@ultimat3/pwa` | Same tier. A notification has no view — the inbox is rendered by the app's page out of `InboxStore.list`, which is data. `pushChannel` takes a STRUCTURAL `Pusher` (`channel-push.ts`), exactly as `mailChannel` takes a `Mailer`: `@ultimat3/pwa`'s `webPush()` satisfies it, and `NotifyPushMessage` mirrors pwa's `PushNotification` field for field — catalog keys, never text. |
|
|
16
16
|
| `@ultimat3/db`, `@ultimat3/entity` | Legal downward, and deliberately not taken. The three tables this package owns are **DDL constants applied by the boot** (`SQL_NOTIFY_DELIVERIES_TABLE`, `SQL_NOTIFY_INBOX_TABLE`, `SQL_NOTIFY_DIGESTS_TABLE`), the way `x_jobs`, `x_idempotency` and `x_audit` are — never `entity()` declarations, which would put framework tables in the app's migration graph and make an app's `x db gen` responsible for them. The Postgres stores take a structural `PgExecutor`, imported as a **type** from `@ultimat3/core` (its one declaration; `bun run flight-copies` refuses another, `X_HELPER_COPY`). |
|
|
17
|
-
| `@ultimat3/
|
|
17
|
+
| `@ultimat3/pwa` for native push | `nativePush()` (APNs + FCM) lives HERE, not in `pwa`: a native app is no progressive web app, and the pusher is `pushChannel`'s seam. It shares no code with `webPush()` — different wire, different auth, different store (`x_push_devices`, not `x_push_subscriptions`). |
|
|
18
|
+
|
|
19
|
+
`@ultimat3/policy` and `@ultimat3/action` are taken for ONE file, `push-register.ts`: device
|
|
20
|
+
registration is an action with a policy (`can()`, an agent denied), exactly as pwa's
|
|
21
|
+
`pushSubscribe`. A notification itself still has no row a policy decides about — the audience is
|
|
22
|
+
`recipients`, and the inbox read surface is the app's query. `pushRegister` / `pushUnregister` are
|
|
23
|
+
factories over `action` and need rows in `PRIMITIVE_FACTORIES`.
|
|
18
24
|
|
|
19
25
|
## What ships, and what must never
|
|
20
26
|
|
|
@@ -53,6 +59,17 @@ A job body runs **before** its checkpoint lands, so both layers are load-bearing
|
|
|
53
59
|
spellings against each other.
|
|
54
60
|
|
|
55
61
|
`attempt.ts` is the only place a send happens, so there is exactly one implementation of that order.
|
|
62
|
+
|
|
63
|
+
**A claim has one owner, and `held` is not `sent`** (`As of 2026-11-02`). `claim` answers
|
|
64
|
+
`owned` (with a `claimId`), `sent` or `held`. A `sending` row fires only past `reclaimAfterMs`
|
|
65
|
+
(`DEFAULT_DELIVERY_RECLAIM_MS`, 5 min, on the job's clock); `settle` is fenced `claim_id = $7 and
|
|
66
|
+
status = 'sending'` as `SQL_IDEMPOTENCY_SETTLE` is, and `false` is logged `notify.settle_fenced`.
|
|
67
|
+
`attemptDelivery` raises `held` as `X_NOTIFY_DELIVERY_HELD` (`retry-after`, the window's remainder
|
|
68
|
+
in `meta.retryAfterSeconds`, deferred by jobs' `rateLimitDeferralMs` with the attempt uncounted) and
|
|
69
|
+
never returns `false` for it: `false` checkpoints the step, and a
|
|
70
|
+
holder that then dies is a notification no retry is left to send (`attempt.test.ts`,
|
|
71
|
+
`ledger-parity.live.test.ts`).
|
|
72
|
+
|
|
56
73
|
The **one at-most-once seam** is a digest flush: `DigestStore.drain` empties the window, and a
|
|
57
74
|
process killed between the drain and its checkpoint loses that batch. It is stated in `digest.ts`
|
|
58
75
|
rather than hidden. `postgresDigestStore` makes the window itself durable and shared — the
|
|
@@ -87,7 +104,7 @@ so an `open` checkpointed with duplicates by an earlier version is repaired on r
|
|
|
87
104
|
| `fanout.ts` | the run body: audience, wait order, gates, delivery |
|
|
88
105
|
| `fanout-digest.ts` · `fanout-walk.ts` | the digest branch, and the state both halves share |
|
|
89
106
|
| `attempt.ts` | claim → send → settle, once |
|
|
90
|
-
| `channel.ts` · `channel-in-app.ts` · `channel-mail.ts` · `channel-push.ts` | the seam and the three shipped channels |
|
|
107
|
+
| `channel.ts` · `channel-in-app.ts` · `channel-mail.ts` · `channel-push.ts` | the seam and the three shipped channels; `pushChannel({ data })` is the data-only form, typed to a `DataPusher` so `webPush()` cannot take it |
|
|
91
108
|
| `ledger.ts` · `ledger-pg.ts` | the delivery ledger, memory and Postgres |
|
|
92
109
|
| `inbox.ts` · `inbox-pg.ts` | the in-app inbox, memory and Postgres |
|
|
93
110
|
| `preferences.ts` · `digest.ts` | the gate and the window, as seams |
|
|
@@ -95,6 +112,13 @@ so an `open` checkpointed with duplicates by an earlier version is repaired on r
|
|
|
95
112
|
| `stores.ts` | the one installer for all four |
|
|
96
113
|
| `retention.ts` | the three sweeps, read off the installed seam |
|
|
97
114
|
| `errors.ts` | this package's `X_NOTIFY_*` codes and their titles |
|
|
115
|
+
| `native-push.ts` | `nativePush()`: every device of a person, dead tokens dropped, bad tokens counted, a credential refusal ending its provider's fan-out, refusals coded |
|
|
116
|
+
| `apns.ts` · `fcm.ts` | one device's send per provider: credential, wire shape, answer |
|
|
117
|
+
| `native-jwt.ts` · `token-cache.ts` · `native-transport.ts` | ES256/RS256 over WebCrypto; one credential per lifetime; the env key read and the refusal read back |
|
|
118
|
+
| `push-message.ts` | a visible push rendered, a data-only push screened to ids |
|
|
119
|
+
| `push-device.ts` · `push-device-pg.ts` | the device seam, memory and Postgres (`x_push_devices`) |
|
|
120
|
+
| `push-register.ts` | `pushRegister` / `pushUnregister`, factories over `action` |
|
|
121
|
+
| `push-errors.ts` | `X_NOTIFY_PUSH_*`, registered in `errors.ts` |
|
|
98
122
|
|
|
99
123
|
One entry point, deliberately: every module runs on the server, so there is no browser half to split
|
|
100
124
|
off.
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Notifications as one declaration: channel fan-out, a preference gate, a digest window, a delivery
|
|
4
4
|
ledger and an in-app inbox.
|
|
5
5
|
|
|
6
|
+
`bun add @ultimat3/notify` · guide: [Notify](https://github.com/developerz-ai/ultimate/wiki/Notify)
|
|
7
|
+
|
|
6
8
|
`notifier()` is a **job factory** — it returns a `JobHandle`, so a notification inherits retry, the
|
|
7
9
|
dead-letter path, cancellation, `x jobs show` and its manifest row. It is not a ninth primitive.
|
|
8
10
|
|
|
@@ -133,6 +135,13 @@ setNotifyStores({
|
|
|
133
135
|
| `inbox` | **none** — `X_NOTIFY_STORE_MISSING` | a message written to nowhere is worse than a refusal |
|
|
134
136
|
| `digest` | **none** — `X_NOTIFY_STORE_MISSING` | same. `memoryDigestStore()` for one process; `postgresDigestStore({ executor })` keeps a window across a restart and gives two replicas ONE window (a partial unique index on the open one), so exactly one run owns its flush |
|
|
135
137
|
|
|
138
|
+
**One attempt owns a delivery.** `claim` answers `owned`, `sent` or `held`: a live `sending` row
|
|
139
|
+
is held for `reclaimAfterMs` (`DEFAULT_DELIVERY_RECLAIM_MS`, 5 min; set it longer than your slowest
|
|
140
|
+
send, on either ledger). A second attempt beside it sends nothing and raises `X_NOTIFY_DELIVERY_HELD`,
|
|
141
|
+
and the job retries once the window has passed. `settle(claim, status, at, claimId)` answers `false`
|
|
142
|
+
and changes nothing for an attempt that no longer owns the row. An app's own `DeliveryLedger`
|
|
143
|
+
implements both.
|
|
144
|
+
|
|
136
145
|
`executor` is a structural `{ query(sql, params) }` — `@ultimat3/db`'s `dbExecutor()` is the
|
|
137
146
|
framework's own. `Bun.sql` does **not** satisfy it.
|
|
138
147
|
|
|
@@ -241,6 +250,75 @@ A recipient with no subscription is not a failure: the delivery settles, as `mai
|
|
|
241
250
|
addressless recipient. A push service that answers 429 or 5xx throws `X_PWA_PUSH_FAILED` after every
|
|
242
251
|
other device was tried, so the job retries; 404/410 deletes the subscription.
|
|
243
252
|
|
|
253
|
+
### Native push — APNs and FCM
|
|
254
|
+
|
|
255
|
+
`nativePush({ devices, apns, fcm })` is a pusher for iOS and Android apps, `As of 2026-11-06`: APNs
|
|
256
|
+
token auth (an ES256 JWT over the team's `.p8` key, HTTP/2) and FCM HTTP v1 (an RS256
|
|
257
|
+
service-account assertion exchanged for an OAuth token), both on WebCrypto through core's
|
|
258
|
+
`outbound()` with a deadline — no SDK. Each credential is minted once and re-minted before it
|
|
259
|
+
expires (APNs at 50 of its 60 minutes, Google's 5 minutes early); concurrent sends share one mint.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import {
|
|
263
|
+
memoryPushDeviceStore,
|
|
264
|
+
nativePush,
|
|
265
|
+
notifier,
|
|
266
|
+
pushChannel,
|
|
267
|
+
pushRegister,
|
|
268
|
+
t,
|
|
269
|
+
} from '@ultimat3/notify';
|
|
270
|
+
|
|
271
|
+
// `postgresPushDeviceStore({ executor: dbExecutor() })` in production: `x_push_devices`.
|
|
272
|
+
const devices = memoryPushDeviceStore();
|
|
273
|
+
|
|
274
|
+
// An action: its route, typed client, MCP tool and policy. Stored against `ctx.actor.id`.
|
|
275
|
+
export const registerDevice = pushRegister({
|
|
276
|
+
permission: 'push:subscribe',
|
|
277
|
+
platforms: ['ios', 'android'],
|
|
278
|
+
devices,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const phones = nativePush({
|
|
282
|
+
devices,
|
|
283
|
+
apns: { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app' },
|
|
284
|
+
fcm: { projectId: 'example-app', clientEmail: 'push@example-app.iam.gserviceaccount.com' },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
export const approvalRequested = notifier({
|
|
288
|
+
name: 'approval.requested',
|
|
289
|
+
input: t.object({ approvalId: t.uuid }),
|
|
290
|
+
tenant: 'none',
|
|
291
|
+
key: (params) => `approval:${params.approvalId}`,
|
|
292
|
+
deliver: [
|
|
293
|
+
// DATA-ONLY: the id, nothing a lock screen or the provider could read. The app wakes and
|
|
294
|
+
// fetches the rest over its own API.
|
|
295
|
+
{
|
|
296
|
+
channel: pushChannel<{ approvalId: string }>({
|
|
297
|
+
pusher: phones,
|
|
298
|
+
data: ({ event }) => ({ approvalId: event.params.approvalId }),
|
|
299
|
+
}),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
| Piece | |
|
|
306
|
+
|---|---|
|
|
307
|
+
| private keys | never options: `ULTIMATE_APNS_PRIVATE_KEY` (the `.p8`) and `ULTIMATE_FCM_PRIVATE_KEY` (the service account's `private_key`), read from the environment when a token is minted — `x secrets set <name>` — and boxed in a `Secret` at once. Missing is `X_ENV_MISSING`, unreadable `X_CONFIG_INVALID`, each naming the variable and never its value |
|
|
308
|
+
| registration | `pushRegister({ permission, platforms, devices })`; `previousToken` forgets the token the OS rotated away. `pushUnregister(…)` on sign-out — a token registered by a second person on the same phone is theirs now. Past `PUSH_DEVICES_PER_ACTOR` (20) the caller's oldest registration is evicted. An agent never registers. An iOS token is hex (it is spliced into the APNs path), an Android one FCM's alphabet — anything else is a 422 |
|
|
309
|
+
| data-only | `pushChannel({ pusher, data })`: APNs `background` at priority 5 with `content-available: 1`, FCM with no `notification` block. At most 8 string keys, 256 characters each, no provider-reserved key (`aps`, `notification`, `google.*`…) — else `X_NOTIFY_PUSH_MESSAGE_INVALID` before a request. `webPush()` has no `sendData`, so handing it a `data` channel is a type error |
|
|
310
|
+
| visible | `pushChannel({ pusher, message })` as for the web: title and body rendered per device locale; `url` travels as data; `topic ?? tag` is the collapse id; `urgency` low/very-low sends at the lower priority. `icon`, `badge`, `actions`, `requireInteraction`, `renotify` have no native meaning and are not sent |
|
|
311
|
+
|
|
312
|
+
| Provider answer | What happens |
|
|
313
|
+
|---|---|
|
|
314
|
+
| 2xx | delivered |
|
|
315
|
+
| APNs 410, FCM `UNREGISTERED` | `X_NOTIFY_PUSH_DEVICE_GONE`: the row is deleted, logged, and the delivery still settles. A bare FCM 404 is **not** gone (a wrong `projectId` is a 404 too) |
|
|
316
|
+
| APNs 400 `BadDeviceToken`, FCM 400 `INVALID_ARGUMENT` on `message.token` | `X_NOTIFY_PUSH_PROVIDER_REFUSED` with `meta.badToken`; counted on the row, which is deleted on the `PUSH_BAD_TOKEN_LIMIT`th (3) answer in a row — never the first (`BadDeviceToken` is also a sandbox token sent to production). A delivery resets the count. An `INVALID_ARGUMENT` on the payload never counts |
|
|
317
|
+
| 401/403, or Google refusing the assertion | `X_NOTIFY_PUSH_PROVIDER_REFUSED` with `meta.credential`: that provider's fan-out stops on its first device, the cached credential the refused request carried is dropped (only that one — a straggler's late 403 keeps a fresh token), and the refusal fails the delivery as `terminal` |
|
|
318
|
+
| another 4xx | `X_NOTIFY_PUSH_PROVIDER_REFUSED`, logged; the next device is tried. When no device took it, the refusal fails the delivery |
|
|
319
|
+
| 429, 5xx, timeout | rethrown after every other device was tried — the job's retry |
|
|
320
|
+
|
|
321
|
+
|
|
244
322
|
## The inbox
|
|
245
323
|
|
|
246
324
|
```ts
|
|
@@ -290,7 +368,10 @@ without bound and every replay would re-send the whole audience.
|
|
|
290
368
|
| `X_NOTIFY_DIGEST_UNSUPPORTED` | a digest window on a bulk channel |
|
|
291
369
|
| `X_NOTIFY_FANOUT_TOO_WIDE` | more recipients than `maxRecipients` (default 500) |
|
|
292
370
|
| `X_NOTIFY_STORE_MISSING` | an inbox or digest channel with no store installed |
|
|
293
|
-
| `X_NOTIFY_DELIVERY_FAILED` | a channel's `deliver` threw; the run retries on its policy |
|
|
371
|
+
| `X_NOTIFY_DELIVERY_FAILED` | a channel's `deliver` threw; the run retries on its policy — unless the channel's error was classified `terminal` (`X_NOTIFY_PUSH_PROVIDER_REFUSED`, a mail provider's permanent refusal), which the wrapper keeps |
|
|
372
|
+
| `X_NOTIFY_PUSH_DEVICE_GONE` | APNs 410 / FCM `UNREGISTERED`: the device row is deleted, nothing fails |
|
|
373
|
+
| `X_NOTIFY_PUSH_PROVIDER_REFUSED` | APNs or FCM refused for good: a credential, a topic or project, a payload limit |
|
|
374
|
+
| `X_NOTIFY_PUSH_MESSAGE_INVALID` | a data-only push carrying more than ids |
|
|
294
375
|
|
|
295
376
|
### Error classes
|
|
296
377
|
|
|
@@ -304,9 +385,20 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
304
385
|
| `NotifyDeliveryFailedError` | `X_NOTIFY_DELIVERY_FAILED` | `src/errors.ts` |
|
|
305
386
|
| `NotifyDigestUnsupportedError` | `X_NOTIFY_DIGEST_UNSUPPORTED` | `src/errors.ts` |
|
|
306
387
|
| `NotifyFanoutTooWideError` | `X_NOTIFY_FANOUT_TOO_WIDE` | `src/errors.ts` |
|
|
388
|
+
| `NotifyPushDeviceGoneError` | `X_NOTIFY_PUSH_DEVICE_GONE` | `src/push-errors.ts` |
|
|
389
|
+
| `NotifyPushMessageInvalidError` | `X_NOTIFY_PUSH_MESSAGE_INVALID` | `src/push-errors.ts` |
|
|
390
|
+
| `NotifyPushProviderRefusedError` | `X_NOTIFY_PUSH_PROVIDER_REFUSED` | `src/push-errors.ts` |
|
|
307
391
|
| `NotifyStoreMissingError` | `X_NOTIFY_STORE_MISSING` | `src/errors.ts` |
|
|
308
392
|
|
|
309
393
|
## Boundary
|
|
310
394
|
|
|
311
395
|
Tier 4. May import tiers 0-3 — enforced by `bun run boundaries`. Its real imports are `core`,
|
|
312
|
-
`schema`, `time` and `jobs`, so tier 4 is its floor. See [`CLAUDE.md`](./CLAUDE.md).
|
|
396
|
+
`schema`, `i18n`, `time`, `policy`, `action` and `jobs`, so tier 4 is its floor. See [`CLAUDE.md`](./CLAUDE.md).
|
|
397
|
+
|
|
398
|
+
## Supporting exports
|
|
399
|
+
|
|
400
|
+
`As of 2026-11-02`.
|
|
401
|
+
|
|
402
|
+
| Name | What |
|
|
403
|
+
|---|---|
|
|
404
|
+
| `DeliveryClaimAnswer` | what `DeliveryLedger.claim` answers: `{ outcome: 'owned', claimId }`, `{ outcome: 'sent' }` or `{ outcome: 'held', retryAfterMs }` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/notify",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.0.0",
|
|
4
4
|
"description": "Notifications: one declaration, many channels — fan-out, preference gate, digest window, delivery ledger, in-app inbox",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"notifications",
|
|
10
|
+
"push",
|
|
11
|
+
"email",
|
|
12
|
+
"inbox"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Notify",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"repository": {
|
|
@@ -35,9 +45,12 @@
|
|
|
35
45
|
"test": "bun test"
|
|
36
46
|
},
|
|
37
47
|
"dependencies": {
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
48
|
+
"@ultimat3/action": "30.0.0",
|
|
49
|
+
"@ultimat3/core": "30.0.0",
|
|
50
|
+
"@ultimat3/i18n": "30.0.0",
|
|
51
|
+
"@ultimat3/jobs": "30.0.0",
|
|
52
|
+
"@ultimat3/policy": "30.0.0",
|
|
53
|
+
"@ultimat3/schema": "30.0.0",
|
|
54
|
+
"@ultimat3/time": "30.0.0"
|
|
42
55
|
}
|
|
43
56
|
}
|
package/src/apns.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Single responsibility: one push to one iOS device over APNs — token auth (an ES256 JWT signed
|
|
2
|
+
// with the team's `.p8` key, cached and re-signed before Apple's hour runs out), HTTP/2, a deadline
|
|
3
|
+
// over the whole call, and the answer read as delivered, gone or refused.
|
|
4
|
+
|
|
5
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
6
|
+
import { outbound } from '@ultimat3/core/outbound';
|
|
7
|
+
import { signJwt } from './native-jwt';
|
|
8
|
+
import type { NativeTransportDeps } from './native-transport';
|
|
9
|
+
import { http2, refusalOf, signingKeyFrom } from './native-transport';
|
|
10
|
+
import {
|
|
11
|
+
NotifyPushDeviceGoneError,
|
|
12
|
+
NotifyPushProviderRefusedError,
|
|
13
|
+
providerReason,
|
|
14
|
+
} from './push-errors';
|
|
15
|
+
import type { NativePush, NativeSender } from './push-message';
|
|
16
|
+
import type { TokenCache } from './token-cache';
|
|
17
|
+
import { tokenCache } from './token-cache';
|
|
18
|
+
|
|
19
|
+
export interface ApnsOptions {
|
|
20
|
+
/** The 10-character Team ID the key belongs to. */
|
|
21
|
+
readonly teamId: string;
|
|
22
|
+
/** The 10-character Key ID of the `.p8` key in `ULTIMATE_APNS_PRIVATE_KEY`. */
|
|
23
|
+
readonly keyId: string;
|
|
24
|
+
/** The app's bundle id — `apns-topic`. */
|
|
25
|
+
readonly topic: string;
|
|
26
|
+
/** `sandbox` for a development build's tokens. Default `production`. */
|
|
27
|
+
readonly environment?: 'production' | 'sandbox' | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const APNS_ORIGINS = {
|
|
31
|
+
production: 'https://api.push.apple.com',
|
|
32
|
+
sandbox: 'https://api.sandbox.push.apple.com',
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Apple refuses a token older than an hour and throttles one re-signed inside 20 minutes. */
|
|
36
|
+
export const APNS_TOKEN_LIFETIME_MS = 60 * 60_000;
|
|
37
|
+
export const APNS_TOKEN_REFRESH_BEFORE_MS = 10 * 60_000;
|
|
38
|
+
|
|
39
|
+
const TEN = /^[A-Z0-9]{10}$/;
|
|
40
|
+
const TOPIC = /^[A-Za-z0-9.-]{1,155}$/;
|
|
41
|
+
|
|
42
|
+
function screen(options: ApnsOptions): void {
|
|
43
|
+
for (const [field, value, rule] of [
|
|
44
|
+
['teamId', options.teamId, TEN],
|
|
45
|
+
['keyId', options.keyId, TEN],
|
|
46
|
+
['topic', options.topic, TOPIC],
|
|
47
|
+
] as const) {
|
|
48
|
+
if (!rule.test(value)) {
|
|
49
|
+
throw new ConfigInvalidError({
|
|
50
|
+
cause: `nativePush({ apns }) ${field} is not what Apple issues (${String(rule)})`,
|
|
51
|
+
fix: `nativePush({ apns: { teamId, keyId, topic } }) # copy ${field} from the Apple developer account's Keys page or the app's bundle id`,
|
|
52
|
+
meta: { field },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The wire: headers and body built from `push` and nothing else. */
|
|
59
|
+
export function apnsRequest(
|
|
60
|
+
push: NativePush,
|
|
61
|
+
topic: string,
|
|
62
|
+
nowSeconds: number,
|
|
63
|
+
): { readonly headers: Record<string, string>; readonly body: string } {
|
|
64
|
+
if (push.kind === 'data') {
|
|
65
|
+
// Background: Apple requires priority 5 for it, and the app is woken with the ids alone.
|
|
66
|
+
return {
|
|
67
|
+
headers: { 'apns-topic': topic, 'apns-push-type': 'background', 'apns-priority': '5' },
|
|
68
|
+
body: JSON.stringify({ ...push.data, aps: { 'content-available': 1 } }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const { push: alert } = push;
|
|
72
|
+
const headers: Record<string, string> = {
|
|
73
|
+
'apns-topic': topic,
|
|
74
|
+
'apns-push-type': 'alert',
|
|
75
|
+
'apns-priority': alert.urgent ? '10' : '5',
|
|
76
|
+
'apns-expiration': String(nowSeconds + alert.ttlSeconds),
|
|
77
|
+
};
|
|
78
|
+
if (alert.collapse !== undefined) headers['apns-collapse-id'] = alert.collapse;
|
|
79
|
+
return {
|
|
80
|
+
headers,
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
aps: { alert: { title: alert.title, body: alert.body } },
|
|
83
|
+
url: alert.url,
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function apnsSender(options: ApnsOptions, deps: NativeTransportDeps): NativeSender {
|
|
89
|
+
screen(options);
|
|
90
|
+
const origin = APNS_ORIGINS[options.environment ?? 'production'];
|
|
91
|
+
const wallMs = (): number => deps.clock.now().getTime();
|
|
92
|
+
const provider: TokenCache = tokenCache(
|
|
93
|
+
async () => {
|
|
94
|
+
const issuedAt = wallMs();
|
|
95
|
+
const key = await signingKeyFrom(deps.env, 'apns');
|
|
96
|
+
const value = await signJwt(
|
|
97
|
+
'ES256',
|
|
98
|
+
key,
|
|
99
|
+
{ kid: options.keyId },
|
|
100
|
+
{ iss: options.teamId, iat: Math.floor(issuedAt / 1000) },
|
|
101
|
+
);
|
|
102
|
+
return { value, expiresAt: issuedAt + APNS_TOKEN_LIFETIME_MS };
|
|
103
|
+
},
|
|
104
|
+
wallMs,
|
|
105
|
+
APNS_TOKEN_REFRESH_BEFORE_MS,
|
|
106
|
+
);
|
|
107
|
+
return {
|
|
108
|
+
async send(token, push, signal) {
|
|
109
|
+
const request = apnsRequest(push, options.topic, Math.floor(wallMs() / 1000));
|
|
110
|
+
const bearer = await provider.get();
|
|
111
|
+
try {
|
|
112
|
+
await outbound(
|
|
113
|
+
{
|
|
114
|
+
// The token was screened hex (`APNS_TOKEN`), so it cannot leave the path segment.
|
|
115
|
+
url: `${origin}/3/device/${token}`,
|
|
116
|
+
vendor: 'apns',
|
|
117
|
+
dial: 'named',
|
|
118
|
+
deadlineMs: deps.deadlineMs,
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { ...request.headers, authorization: `bearer ${bearer}` },
|
|
121
|
+
body: request.body,
|
|
122
|
+
signal,
|
|
123
|
+
},
|
|
124
|
+
{ fetch: http2(deps.fetch), now: () => deps.clock.monotonic() },
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const refusal = refusalOf(error);
|
|
128
|
+
if (refusal === undefined) throw error;
|
|
129
|
+
const reason = providerReason(refusal.body?.['reason']);
|
|
130
|
+
if (refusal.status === 410) {
|
|
131
|
+
throw new NotifyPushDeviceGoneError({ provider: 'apns', status: 410, reason });
|
|
132
|
+
}
|
|
133
|
+
// Expired or revoked provider token: the next send signs a fresh one.
|
|
134
|
+
if (refusal.status === 403) provider.invalidate(bearer);
|
|
135
|
+
throw new NotifyPushProviderRefusedError({
|
|
136
|
+
provider: 'apns',
|
|
137
|
+
status: refusal.status,
|
|
138
|
+
reason,
|
|
139
|
+
// Also a sandbox token sent to production, so counted per row, never read as gone.
|
|
140
|
+
badToken: refusal.status === 400 && reason === 'BadDeviceToken',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/attempt.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// send that has not happened; claiming after would let two attempts pass the check together.
|
|
7
7
|
|
|
8
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
9
|
+
import { classifyThrown, logger } from '@ultimat3/core';
|
|
10
|
+
import { NotifyDeliveryFailedError, NotifyDeliveryHeldError } from './errors';
|
|
11
|
+
import type { DeliveryClaim, DeliveryLedger, DeliveryStatus } from './ledger';
|
|
11
12
|
|
|
12
13
|
export interface AttemptInput {
|
|
13
14
|
readonly ledger: DeliveryLedger;
|
|
@@ -18,7 +19,8 @@ export interface AttemptInput {
|
|
|
18
19
|
* a bulk one. Reported in the refusal, because "the Slack post failed" and "one of 400 emails
|
|
19
20
|
* failed" are different incidents. */
|
|
20
21
|
readonly recipients: number;
|
|
21
|
-
|
|
22
|
+
/** The job's clock, and nothing else: `send` closes over the rest. */
|
|
23
|
+
readonly ctx: Pick<Ctx, 'now'>;
|
|
22
24
|
send(signal: AbortSignal): Promise<void> | void;
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -26,23 +28,53 @@ export interface AttemptInput {
|
|
|
26
28
|
* `true` when this attempt sent, `false` when the ledger already held a completed delivery — the
|
|
27
29
|
* replay case, and the whole reason this function exists.
|
|
28
30
|
*
|
|
31
|
+
* A claim another attempt holds raises `X_NOTIFY_DELIVERY_HELD`, never `false`: `false` checkpoints
|
|
32
|
+
* the step as done, and a holder that then dies is a notification no retry is left to send.
|
|
33
|
+
*
|
|
29
34
|
* A throwing channel settles `failed` and then rethrows as `X_NOTIFY_DELIVERY_FAILED`: the row
|
|
30
35
|
* stays re-claimable, so the job's own retry policy decides whether it goes out, and the dead
|
|
31
|
-
* letter names the channel rather than whatever the provider's SDK threw.
|
|
36
|
+
* letter names the channel rather than whatever the provider's SDK threw. A channel error
|
|
37
|
+
* classified `terminal` (a provider refusing a credential for good) keeps that on the wrapper:
|
|
38
|
+
* read as `retryable`, every attempt re-sent the alert to each device that already took it.
|
|
32
39
|
*/
|
|
33
40
|
export async function attemptDelivery(input: AttemptInput, signal: AbortSignal): Promise<boolean> {
|
|
34
|
-
|
|
41
|
+
const answer = await input.ledger.claim(input.claim, input.ctx.now());
|
|
42
|
+
if (answer.outcome === 'sent') return false;
|
|
43
|
+
if (answer.outcome === 'held') {
|
|
44
|
+
throw new NotifyDeliveryHeldError({
|
|
45
|
+
notifier: input.notifier,
|
|
46
|
+
channel: input.channel,
|
|
47
|
+
retryAfterMs: answer.retryAfterMs,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const settle = async (status: DeliveryStatus): Promise<void> => {
|
|
51
|
+
const recorded = await input.ledger.settle(
|
|
52
|
+
input.claim,
|
|
53
|
+
status,
|
|
54
|
+
input.ctx.now(),
|
|
55
|
+
answer.claimId,
|
|
56
|
+
);
|
|
57
|
+
// The one trace of a send that outlasted its window: its reclaimer may have sent as well.
|
|
58
|
+
if (!recorded) {
|
|
59
|
+
logger.warn('notify.settle_fenced', {
|
|
60
|
+
notifier: input.notifier,
|
|
61
|
+
channel: input.channel,
|
|
62
|
+
status,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
35
66
|
try {
|
|
36
67
|
await input.send(signal);
|
|
37
68
|
} catch (error) {
|
|
38
|
-
await
|
|
69
|
+
await settle('failed');
|
|
39
70
|
throw new NotifyDeliveryFailedError({
|
|
40
71
|
notifier: input.notifier,
|
|
41
72
|
channel: input.channel,
|
|
42
73
|
recipients: input.recipients,
|
|
43
74
|
cause: error,
|
|
75
|
+
retry: classifyThrown(error) === 'terminal' ? 'terminal' : undefined,
|
|
44
76
|
});
|
|
45
77
|
}
|
|
46
|
-
await
|
|
78
|
+
await settle('sent');
|
|
47
79
|
return true;
|
|
48
80
|
}
|
package/src/channel-push.ts
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// tag: `post:${event.params.postId}`,
|
|
14
14
|
// }),
|
|
15
15
|
// })
|
|
16
|
+
//
|
|
17
|
+
// `nativePush()` (this package) satisfies it too, for iOS and Android, and adds the data-only
|
|
18
|
+
// form: `pushChannel({ pusher: nativePush({ devices, apns, fcm }), data: ({ event }) => ({ id }) })`.
|
|
16
19
|
|
|
17
20
|
import type { DeliveryArgs, NotifyChannel } from './channel';
|
|
18
21
|
import { deliveryChannel } from './channel';
|
|
@@ -57,6 +60,21 @@ export interface Pusher {
|
|
|
57
60
|
}): Promise<void> | void;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A pusher that can send a DATA-ONLY push — ids that wake the app and show nothing, on the lock
|
|
65
|
+
* screen or anywhere. `@ultimat3/notify`'s `nativePush()` is one; `webPush()` is not and cannot be
|
|
66
|
+
* (a browser push must show a notification), so handing it to `pushChannel({ data })` is a type
|
|
67
|
+
* error rather than a push that renders `undefined`.
|
|
68
|
+
*/
|
|
69
|
+
export interface DataPusher {
|
|
70
|
+
sendData(push: {
|
|
71
|
+
readonly to: string;
|
|
72
|
+
/** String ids only; screened by the transport (`X_NOTIFY_PUSH_MESSAGE_INVALID`). */
|
|
73
|
+
readonly data: Readonly<Record<string, string>>;
|
|
74
|
+
readonly signal: AbortSignal;
|
|
75
|
+
}): Promise<void> | void;
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
export const PUSH_CHANNEL = 'push';
|
|
61
79
|
|
|
62
80
|
export interface PushChannelOptions<Params> {
|
|
@@ -69,19 +87,40 @@ export interface PushChannelOptions<Params> {
|
|
|
69
87
|
readonly name?: string | undefined;
|
|
70
88
|
}
|
|
71
89
|
|
|
90
|
+
export interface DataPushChannelOptions<Params> {
|
|
91
|
+
readonly pusher: DataPusher;
|
|
92
|
+
/**
|
|
93
|
+
* The ids this push carries — and nothing a person would read: it crosses the provider and
|
|
94
|
+
* reaches the device unshown, and the app fetches the content over its own authenticated API.
|
|
95
|
+
*/
|
|
96
|
+
readonly data: (args: DeliveryArgs<Params>) => Readonly<Record<string, string>>;
|
|
97
|
+
readonly name?: string | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
/**
|
|
73
101
|
* One delivery per recipient, to every device they subscribed. A recipient with no subscription is
|
|
74
102
|
* NOT a failure — the pusher sends to nobody and the delivery settles, exactly as `mailChannel`
|
|
75
103
|
* settles an addressless recipient: a retry would find the same empty list.
|
|
104
|
+
*
|
|
105
|
+
* With `data` instead of `message`, the push is data-only (`contentAvailable`): see `DataPusher`.
|
|
76
106
|
*/
|
|
77
107
|
export function pushChannel<Params = unknown>(
|
|
78
108
|
options: PushChannelOptions<Params>,
|
|
109
|
+
): NotifyChannel<Params>;
|
|
110
|
+
export function pushChannel<Params = unknown>(
|
|
111
|
+
options: DataPushChannelOptions<Params>,
|
|
112
|
+
): NotifyChannel<Params>;
|
|
113
|
+
export function pushChannel<Params = unknown>(
|
|
114
|
+
options: PushChannelOptions<Params> | DataPushChannelOptions<Params>,
|
|
79
115
|
): NotifyChannel<Params> {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
signal: args.signal,
|
|
116
|
+
if ('data' in options) {
|
|
117
|
+
const { pusher, data } = options;
|
|
118
|
+
return deliveryChannel<Params>(options.name ?? PUSH_CHANNEL, async (args) => {
|
|
119
|
+
await pusher.sendData({ to: args.recipient.id, data: data(args), signal: args.signal });
|
|
85
120
|
});
|
|
121
|
+
}
|
|
122
|
+
const { pusher, message } = options;
|
|
123
|
+
return deliveryChannel<Params>(options.name ?? PUSH_CHANNEL, async (args) => {
|
|
124
|
+
await pusher.send({ to: args.recipient.id, message: message(args), signal: args.signal });
|
|
86
125
|
});
|
|
87
126
|
}
|
package/src/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// No `docs:` line, and that is deliberate: `UltimateError`'s constructor resolves the registered
|
|
4
4
|
// descriptor, whose default is `ERROR_DOCS_URL` in @ultimat3/core. A URL written out here is a
|
|
5
5
|
// second answer to a question core already answers, and the last one went stale host and all.
|
|
6
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
7
|
import {
|
|
7
8
|
registerErrorCodes,
|
|
8
9
|
registerErrorRetry,
|
|
@@ -17,6 +18,10 @@ export const NOTIFY_ERROR_CODES = [
|
|
|
17
18
|
'X_NOTIFY_STORE_MISSING',
|
|
18
19
|
'X_NOTIFY_DELIVERY_FAILED',
|
|
19
20
|
'X_NOTIFY_DIGEST_UNSUPPORTED',
|
|
21
|
+
'X_NOTIFY_DELIVERY_HELD',
|
|
22
|
+
'X_NOTIFY_PUSH_DEVICE_GONE',
|
|
23
|
+
'X_NOTIFY_PUSH_PROVIDER_REFUSED',
|
|
24
|
+
'X_NOTIFY_PUSH_MESSAGE_INVALID',
|
|
20
25
|
] as const;
|
|
21
26
|
|
|
22
27
|
export type NotifyErrorCode = (typeof NOTIFY_ERROR_CODES)[number];
|
|
@@ -28,6 +33,10 @@ export const NOTIFY_ERROR_TITLES: Readonly<Record<NotifyErrorCode, string>> = {
|
|
|
28
33
|
X_NOTIFY_STORE_MISSING: 'a channel needs a store nothing installed',
|
|
29
34
|
X_NOTIFY_DELIVERY_FAILED: 'a channel did not accept the delivery',
|
|
30
35
|
X_NOTIFY_DIGEST_UNSUPPORTED: 'a digest window is declared on a bulk channel',
|
|
36
|
+
X_NOTIFY_DELIVERY_HELD: 'another attempt holds this delivery',
|
|
37
|
+
X_NOTIFY_PUSH_DEVICE_GONE: 'the push provider says a device token is dead',
|
|
38
|
+
X_NOTIFY_PUSH_PROVIDER_REFUSED: 'APNs or FCM refused a push for good',
|
|
39
|
+
X_NOTIFY_PUSH_MESSAGE_INVALID: 'a data-only push carries more than an id',
|
|
31
40
|
};
|
|
32
41
|
|
|
33
42
|
// One unconditional call, so a second package claiming one of notify's codes throws
|
|
@@ -66,6 +75,15 @@ registerErrorRetry({
|
|
|
66
75
|
// declaring it is what makes that an answer rather than a coincidence, and what stops a later
|
|
67
76
|
// sweep through this file from making the whole package terminal.
|
|
68
77
|
X_NOTIFY_DELIVERY_FAILED: 'retryable',
|
|
78
|
+
// "Not yet": the holder finishes or its window lapses, and the refusal names how long that is.
|
|
79
|
+
// Waited out with the attempt uncounted and `maxDelay` no cap (`rateLimitDeferralMs`, jobs).
|
|
80
|
+
X_NOTIFY_DELIVERY_HELD: 'retry-after',
|
|
81
|
+
// Native push (`push-errors.ts`). A refusal the provider gives again — a bad credential, a wrong
|
|
82
|
+
// topic, a payload over its limit — and a data push carrying more than ids: no attempt fixes
|
|
83
|
+
// either. `X_NOTIFY_PUSH_DEVICE_GONE` is absent: `nativePush()` catches it, deletes the row, and
|
|
84
|
+
// it never leaves a send.
|
|
85
|
+
X_NOTIFY_PUSH_PROVIDER_REFUSED: 'terminal',
|
|
86
|
+
X_NOTIFY_PUSH_MESSAGE_INVALID: 'terminal',
|
|
69
87
|
});
|
|
70
88
|
|
|
71
89
|
/** Refused where it is written: a notifier with no channel is a job that fans out to nobody. */
|
|
@@ -158,6 +176,27 @@ export class NotifyDigestUnsupportedError extends UltimateError {
|
|
|
158
176
|
}
|
|
159
177
|
}
|
|
160
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A live attempt holds this delivery's claim. Never read as `sent`: that checkpoints the step, and a
|
|
181
|
+
* holder that then dies is a notification no retry is left to send. `retryAfterSeconds` is the one
|
|
182
|
+
* spelling a job's retry waits out, so the next attempt lands past the holder's window.
|
|
183
|
+
*/
|
|
184
|
+
export class NotifyDeliveryHeldError extends UltimateError {
|
|
185
|
+
constructor(input: { notifier: string; channel: string; retryAfterMs: number }) {
|
|
186
|
+
super({
|
|
187
|
+
code: 'X_NOTIFY_DELIVERY_HELD',
|
|
188
|
+
cause: `another attempt holds the "${input.channel}" delivery of notifier "${input.notifier}", so this one neither sent it nor counted it as sent; the claim can be reclaimed in ${String(input.retryAfterMs)} ms`,
|
|
189
|
+
fix: 'x jobs list --json # the run retries on its own once the claim can be reclaimed; to shorten that, set reclaimAfterMs on postgresDeliveryLedger()',
|
|
190
|
+
meta: {
|
|
191
|
+
notifier: input.notifier,
|
|
192
|
+
channel: input.channel,
|
|
193
|
+
// One past the window: a retry landing exactly on its edge is still refused.
|
|
194
|
+
retryAfterSeconds: Math.ceil(input.retryAfterMs / 1_000) + 1,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
/**
|
|
162
201
|
* A channel's `deliver` threw. Wrapped rather than rethrown so the dead-letter row carries a
|
|
163
202
|
* stable code and names the channel — a raw provider error names only itself.
|
|
@@ -166,9 +205,17 @@ export class NotifyDigestUnsupportedError extends UltimateError {
|
|
|
166
205
|
* rejection is routinely an object whose `toString` throws (`bun run scripts/catch-render.ts`).
|
|
167
206
|
*/
|
|
168
207
|
export class NotifyDeliveryFailedError extends UltimateError {
|
|
169
|
-
constructor(input: {
|
|
208
|
+
constructor(input: {
|
|
209
|
+
notifier: string;
|
|
210
|
+
channel: string;
|
|
211
|
+
recipients: number;
|
|
212
|
+
cause: unknown;
|
|
213
|
+
/** The channel's own `terminal`, carried over: absent, the code's `retryable` stands. */
|
|
214
|
+
retry?: ErrorRetry | undefined;
|
|
215
|
+
}) {
|
|
170
216
|
super({
|
|
171
217
|
code: 'X_NOTIFY_DELIVERY_FAILED',
|
|
218
|
+
retry: input.retry,
|
|
172
219
|
cause: `channel "${input.channel}" of notifier "${input.notifier}" failed for ${String(input.recipients)} recipient(s): ${renderThrowable(input.cause)}`,
|
|
173
220
|
// `x jobs list` first, and never `x jobs show` handed a notifier NAME: that command takes a job id
|
|
174
221
|
// positional and resolves it through `inspectJob`, which answers `X_JOB_UNKNOWN` for
|