@spfn/core 0.3.0-beta.32 → 0.3.0-beta.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.32",
3
+ "version": "0.3.0-beta.34",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
package/src/db/README.md CHANGED
@@ -18,14 +18,14 @@ import {
18
18
  findOne, findMany, create, createMany, upsert,
19
19
  updateOne, updateMany, deleteOne, deleteMany, count,
20
20
  // Repository
21
- BaseRepository, RepositoryError,
21
+ BaseRepository, TenantRepository, RepositoryError,
22
22
  // Connection (sub-module: manager/)
23
23
  initDatabase, getDatabase, closeDatabase, getDatabaseInfo,
24
24
  // Transactions (sub-module: transaction/)
25
25
  Transactional, runWithTransaction, getTransaction,
26
26
  onBeforeCommit, onAfterCommit, onAfterRollback,
27
27
  // Schema helpers (sub-module: schema/)
28
- id, uuid, timestamps, foreignKey, enumText, typedJsonb, createSchema,
28
+ id, uuid, timestamps, foreignKey, tenantKey, enumText, typedJsonb, createSchema,
29
29
  // Error mapping
30
30
  fromPostgresError,
31
31
  } from '@spfn/core/db';
@@ -44,8 +44,9 @@ Everything re-exported from `@spfn/core/db` (`src/db/index.ts`):
44
44
  `findOne`, `findMany`, `create`, `createMany`, `upsert`, `updateOne`, `updateMany`,
45
45
  `deleteOne`, `deleteMany`, `count`
46
46
 
47
- **Repository** (`repository.ts`):
48
- `BaseRepository` (abstract class), `RepositoryError` (class), `RepositoryDatabase` (type)
47
+ **Repository** (`repository.ts`, `tenant/`):
48
+ `BaseRepository` (abstract class), `TenantRepository` (abstract class, tables shared by many
49
+ tenants), `RepositoryError` (class), `RepositoryDatabase` (type), `CountOptions` (type)
49
50
 
50
51
  **PostgreSQL error mapping** (`postgres-errors.ts`):
51
52
  `fromPostgresError`
@@ -69,7 +70,7 @@ Types: `TransactionContext`, `TransactionDB`, `TransactionalOptions`,
69
70
 
70
71
  **Schema helpers** (sub-module — see [schema/README](./schema/README.md), re-exported via
71
72
  `export * from './schema'`):
72
- `id`, `uuid`, `timestamps`, `foreignKey`, `optionalForeignKey`, `auditFields`,
73
+ `id`, `uuid`, `timestamps`, `foreignKey`, `optionalForeignKey`, `tenantKey`, `auditFields`,
73
74
  `publishingFields`, `softDelete`, `verificationTimestamp`, `utcTimestamp`, `enumText`,
74
75
  `typedJsonb`, `createSchema`, `packageNameToSchema`, `getSchemaInfo`
75
76
 
@@ -250,6 +251,7 @@ inside repository methods.
250
251
  |--------|---------|
251
252
  | `_findOne(table, where)` | `T \| null` |
252
253
  | `_findMany(table, options?)` | `T[]` |
254
+ | `_findManyKeyset(table, { cursorColumn, limit, after?, order?, where? })` | `T[]` |
253
255
  | `_create(table, data)` | `T` |
254
256
  | `_createMany(table, data[])` | `T[]` |
255
257
  | `_upsert(table, data, { target, set? })` | `T` |
@@ -257,7 +259,12 @@ inside repository methods.
257
259
  | `_updateMany(table, where, data)` | `T[]` |
258
260
  | `_deleteOne(table, where)` | `T \| null` |
259
261
  | `_deleteMany(table, where)` | `T[]` |
260
- | `_count(table, where?)` | `number` |
262
+ | `_count(table, where?, { cap? })` | `number` — with `cap`, at most `cap + 1` |
263
+
264
+ `_count` with `cap` stops counting past `cap` rows: it reads at most `cap + 1` rows through a
265
+ limited subquery, and a result of `cap + 1` means "more than `cap`". A list label such as
266
+ "1,000+" needs nothing more, and the count no longer scans every matching row on each
267
+ request.
261
268
 
262
269
  These are `protected` — calling `userRepo._findOne(...)` from outside is a TypeScript
263
270
  error. Expose a domain method (`findById`) instead.
@@ -324,6 +331,102 @@ export class UserRepository extends BaseRepository<AppRelations, AppDatabase>
324
331
 
325
332
  ---
326
333
 
334
+ ## TenantRepository (`tenant/`) — tables shared by many tenants
335
+
336
+ For an app that keeps many tenants (workspaces, organizations, customers) in the same
337
+ tables. Mark a table's tenant column with `tenantKey()`; extend `TenantRepository` instead of
338
+ `BaseRepository`. Every method takes the tenant id first and writes the tenant condition
339
+ into the query itself, so a query this class builds cannot reach another tenant's rows.
340
+ Packages that never call `tenantKey()` are unaffected.
341
+
342
+ ```typescript
343
+ import { createSchema, foreignKey, id, tenantKey, timestamps, TenantRepository } from '@spfn/core/db';
344
+ import { index, text } from 'drizzle-orm/pg-core';
345
+ import { eq } from 'drizzle-orm';
346
+
347
+ const schema = createSchema('@acme/notes');
348
+
349
+ export const authors = schema.table('authors', {
350
+ id: id(),
351
+ tenantId: tenantKey(), // tenant_id bigint not null
352
+ name: text('name').notNull(),
353
+ }, (t) => [index('authors_tenant_id_idx').on(t.tenantId, t.id)]);
354
+
355
+ export const notes = schema.table('notes', {
356
+ id: id(),
357
+ tenantId: tenantKey(),
358
+ authorId: foreignKey('author', () => authors.id),
359
+ title: text('title').notNull(),
360
+ ...timestamps(),
361
+ }, (t) => [index('notes_tenant_id_idx').on(t.tenantId, t.id)]);
362
+
363
+ export class NoteRepository extends TenantRepository
364
+ {
365
+ async list(tenantId: number, after?: number)
366
+ {
367
+ return this._findManyKeyset(tenantId, notes, { cursorColumn: notes.id, after, limit: 50 });
368
+ }
369
+
370
+ async listWithAuthor(tenantId: number)
371
+ {
372
+ return this._select(tenantId, { title: notes.title, author: authors.name })
373
+ .from(notes)
374
+ .innerJoin(authors, eq(notes.authorId, authors.id));
375
+ }
376
+ }
377
+ ```
378
+
379
+ `listWithAuthor(7)` runs:
380
+
381
+ ```sql
382
+ select "notes"."title", "authors"."name"
383
+ from "acme_notes"."notes"
384
+ inner join "acme_notes"."authors"
385
+ on ("notes"."author_id" = "authors"."id" and "authors"."tenant_id" = $1)
386
+ where "notes"."tenant_id" = $2
387
+ ```
388
+
389
+ | Method | Tenant handling |
390
+ |--------|-----------------|
391
+ | `_findOne(tenantId, table, where)` | added to WHERE |
392
+ | `_findMany(tenantId, table, options?)` | added to WHERE |
393
+ | `_findManyKeyset(tenantId, table, options)` | added to WHERE |
394
+ | `_count(tenantId, table, where?, { cap? })` | added to WHERE |
395
+ | `_create` / `_createMany(tenantId, table, data)` | written to the tenant column; a different tenant value in `data` throws, and `_createMany` checks every row before writing any |
396
+ | `_upsert(tenantId, table, data, { target, set? })` | written to the tenant column; `target` must include the tenant column, `set` must not change it |
397
+ | `_updateOne` / `_updateMany(tenantId, table, where, data)` | added to WHERE; `data` naming the tenant column throws |
398
+ | `_deleteOne` / `_deleteMany(tenantId, table, where)` | added to WHERE |
399
+ | `_select(tenantId, fields?)` | the `from` table in WHERE, each joined tenant-aware table in its ON clause |
400
+
401
+ - **The tenant id must be a positive safe integer.** Anything else — `0`, `NaN`,
402
+ `undefined`, a string — throws before a query is built.
403
+ - **Every method takes tenant-aware tables only.** An ordinary table throws; it can still be
404
+ joined in `_select`, where it gets no tenant condition.
405
+ - **Joined tables get the condition in ON, not WHERE.** In WHERE, a left join's unmatched
406
+ rows would be filtered out and it would behave as an inner join. `_select` scopes
407
+ `innerJoin` and `leftJoin` (aliases included); `rightJoin`, `fullJoin`, `crossJoin`, the
408
+ lateral joins and set operators (`union`, … — the methods and the standalone functions)
409
+ throw — write those on `this.readDb`.
410
+ - **A view cannot be joined.** A view's columns are its own, so `_select` cannot tell which
411
+ holds the tenant; joining one throws, as `.from(view)` does. Join the underlying table.
412
+ - **The upsert target must include the tenant column.** With `target: [table.id]`, an insert
413
+ could collide with another tenant's row and update it.
414
+ - **Update and delete still need a where of their own,** as in `BaseRepository`: the tenant
415
+ condition alone does not count, so a bare call cannot rewrite or delete a whole tenant.
416
+ - **Index on the tenant column first.** `tenantKey()` cannot create an index (a Drizzle
417
+ column cannot); declare each index in the table's third argument starting with the tenant
418
+ column, e.g. `(tenant_id, id)` for keyset pages.
419
+ - **`this.db` / `this.readDb` remain available and are not scoped,** nor is a subquery you
420
+ pass as a condition or join source. In local and development runs (`NODE_ENV` `local` or
421
+ `development`; an unset `NODE_ENV` is `local`), the database connection logs a warning —
422
+ once per SQL text, with the SQL — when a query reads, updates or deletes a tenant-aware
423
+ table without a condition on its tenant column. It reads SQL text, so it is a development
424
+ aid, not isolation: an alias it cannot resolve, a raw `sql` template or an unusual shape can
425
+ make it miss a query or warn about a scoped one. A failure inside the check never fails the
426
+ query. Other environments attach no logger.
427
+
428
+ ---
429
+
327
430
  ## Where clauses & query options (`query-utils.ts`)
328
431
 
329
432
  Both the helpers and `BaseRepository._*` methods accept the same two `where` forms,
@@ -11,7 +11,7 @@ re-exported from `@spfn/core/db`. Import from there.
11
11
  ```typescript
12
12
  import {
13
13
  id, uuid, timestamps,
14
- foreignKey, optionalForeignKey,
14
+ foreignKey, optionalForeignKey, tenantKey,
15
15
  auditFields, publishingFields, softDelete,
16
16
  verificationTimestamp, utcTimestamp, enumText, typedJsonb,
17
17
  createSchema, packageNameToSchema, getSchemaInfo,
@@ -36,6 +36,7 @@ Column helpers (`entity-helper.ts`):
36
36
  - `timestamps()` — `createdAt` + `updatedAt` (timestamptz, not null, both written by the database clock)
37
37
  - `foreignKey(name, reference, options?)` — required FK (bigint, cascade by default)
38
38
  - `optionalForeignKey(name, reference, options?)` — nullable FK (set null by default)
39
+ - `tenantKey(name?)` — not-null bigint tenant column that makes a table tenant-aware (`tenant_id` by default)
39
40
  - `auditFields()` — `createdBy` + `updatedBy` (text, nullable)
40
41
  - `publishingFields()` — `publishedAt` (timestamptz, nullable) + `publishedBy` (text)
41
42
  - `softDelete()` — `deletedAt` (timestamptz, nullable) + `deletedBy` (text)
@@ -141,6 +142,23 @@ categoryId: optionalForeignKey('category', () => cats.id), // category_id BIGINT
141
142
  - There is **no** `onUpdate` option on these helpers. For `onUpdate` (or FK to a uuid PK),
142
143
  use drizzle's table-level `foreignKey({ columns, foreignColumns, onUpdate })` instead.
143
144
 
145
+ ### `tenantKey(name?)`
146
+
147
+ The tenant column of a table that many tenants share: a not-null `bigint` (JS `number`),
148
+ named `tenant_id` unless you pass a name. Declaring it makes the table tenant-aware —
149
+ `TenantRepository` requires it and scopes every query to one tenant (see the
150
+ [db README](../README.md#tenantrepository-tenant--tables-shared-by-many-tenants)). One per
151
+ table; a second throws when the schema module loads. It creates no index — declare indexes
152
+ in the table's third argument, each starting with the tenant column:
153
+
154
+ ```typescript
155
+ export const notes = schema.table('notes', {
156
+ id: id(),
157
+ tenantId: tenantKey(),
158
+ title: text('title').notNull(),
159
+ }, (t) => [index('notes_tenant_id_idx').on(t.tenantId, t.id)]);
160
+ ```
161
+
144
162
  ### `auditFields()`, `publishingFields()`, `softDelete()`
145
163
 
146
164
  ```typescript
@@ -59,7 +59,9 @@ From `@spfn/core/event/sse`:
59
59
 
60
60
  - `createSSEHandler(router, config?, tokenManager?)` — Hono GET handler
61
61
  - `SSETokenManager` (class), `CacheTokenStore` (class)
62
- - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
62
+ - `closeSSEConnectionsByKey(keyId)` — close the streams a device key opened, on every pod
63
+ (see [Closing the streams of a revoked device key](#closing-the-streams-of-a-revoked-device-key))
64
+ - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEKeyVerifier`, `SSEMessage`,
63
65
  `SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
64
66
  `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
65
67
  `SSEConnectionState`, `SSEUnsubscribe`, `ConnectedPayload`
@@ -269,10 +271,10 @@ can't ride along on the stream request. SPFN uses a **token exchange**: an authe
269
271
  ```
270
272
  Client Server
271
273
  │ POST /events/token (Bearer JWT) │ ← protected by config.middlewares (e.g. authenticate)
272
- │ ───────────────────────────────► │ getSubject(c) → issue one-time token
274
+ │ ───────────────────────────────► │ getSubject(c), getKeyId(c) → issue one-time token
273
275
  │ ◄───────────────────────────── │ { token: "..." } (default TTL 30s)
274
276
  │ GET /events/stream?token=…&events=…
275
- │ ───────────────────────────────► │ verify+consume token (one-time)
277
+ │ ───────────────────────────────► │ consume token (one-time) → verifyKey(keyId) when tagged
276
278
  │ │ → authorize(subject, events)
277
279
  │ │ → resolvePrincipal(subject, request) (once)
278
280
  │ │ → event.canSubscribe(ctx) (once per event)
@@ -295,7 +297,7 @@ export default defineServerConfig()
295
297
  auth: { enabled: true },
296
298
  })
297
299
  .build();
298
- // → POST /events/token (mints token; subject = c.get('auth').userId by default)
300
+ // → POST /events/token (mints token; subject = c.get('auth').userId, keyId = c.get('auth').keyId by default)
299
301
  // → GET /events/stream?token=…&events=…
300
302
  ```
301
303
 
@@ -359,6 +361,54 @@ export default defineServerConfig()
359
361
  ```
360
362
 
361
363
  When `tokenManager` is provided, `store`/`tokenTtl` are ignored (the external manager owns them).
364
+ `@spfn/auth`'s manager also checks the device key at consume (`verifyKey`, below), so sharing it
365
+ is what makes a token minted before a sign-out useless after it.
366
+
367
+ ### Closing the streams of a revoked device key
368
+
369
+ A stream outlives the token that opened it: the token is checked once, at open, and pings keep
370
+ the stream alive. So the token carries the **device key** it was minted for, and revoking that
371
+ key ends what it opened.
372
+
373
+ - **The token is tagged.** The token endpoint passes `getKeyId(c)` (default
374
+ `c.get('auth')?.keyId`; an app without `auth` is fine) to `issue(subject, { keyId })`. An
375
+ empty, `null` or `undefined` key leaves the token untagged — such a stream is never closed by
376
+ key. A non-device value (`'service'`) is stored as given; nothing asks to close it.
377
+ - **A token minted before the revocation is refused at open.** `SSETokenManagerConfig.verifyKey`
378
+ runs when a tagged token is consumed; `false` or a throw refuses it (`401`). The token is
379
+ consumed first, so a refused token is never retried. The throw is warned without its text.
380
+ - **`closeSSEConnectionsByKey(keyId)` ends the open ones.** It closes, on this process, every
381
+ stream whose token carried `keyId` — every `createSSEHandler` of the process, not one — and a
382
+ connect of that key still in admission ends at open, with no `connected` frame and no
383
+ subscription. With a cache (resolved lazily from `getCache()`, so a pod serving no events
384
+ publishes too) it then publishes `{ kind: 'close', keyId }` on the same control channel as
385
+ `grant` / `revoke`, `<channelPrefix>$control`
386
+ ([Multi-instance broadcast](#multi-instance-broadcast-cross-pod-fan-out)), so every other pod
387
+ whose events are wired through the cache closes its own — whatever events it serves; a pod
388
+ skips its own echo, and a close without a string `keyId` is malformed and ignored. A failed publish is warned and swallowed: the promise
389
+ always resolves. A close this pod cannot publish while the transport's breaker is open, or
390
+ whose publish fails, closes this pod's streams at once and is re-sent when the breaker
391
+ cooldown ends and whenever the next publish succeeds; while it is pending, the key's streams on
392
+ other pods stay open. A held close is lost only when the Redis outage outlasts this pod (its
393
+ shutdown disposes held closes) or when more than 1000 closes are held (the oldest are dropped);
394
+ those streams then stay open until they disconnect, and their next token mint is refused. At most 1000 closes wait; past
395
+ that the oldest is dropped, with a warning that gives the count. The log says how many closed and why — never the key id.
396
+
397
+ ```typescript
398
+ import { closeSSEConnectionsByKey } from '@spfn/core/event/sse';
399
+ import { onAfterCommit } from '@spfn/core/db';
400
+
401
+ // after your own revocation statement
402
+ onAfterCommit(() => closeSSEConnectionsByKey(revokedKeyId));
403
+ ```
404
+
405
+ `@spfn/auth` does this for every key revocation (sign out one device, sign out everywhere,
406
+ logout, password change and reset, account deletion, rotation, renewal, re-login): an app on
407
+ `tokenManager: () => getOneTimeTokenManager()` gets it with no configuration change. The client
408
+ whose stream closed reconnects within `reconnectDelay`; its token mint then answers `401` and
409
+ it stops (`onClose('unauthorized')`, see the [client](#createauthsseclientconfig--with-auth-recommended)).
410
+ A device whose key was *replaced* (rotation, renewal) reconnects with the new key, which the
411
+ response installed meanwhile.
362
412
 
363
413
  ### Authorization hooks
364
414
 
@@ -553,6 +603,7 @@ client.subscribe({
553
603
  | `store` | `SSETokenStore` | auto (Cache → InMemory) | token storage backend |
554
604
  | `tokenManager` | `SSETokenManager \| () => SSETokenManager` | — | reuse an external manager (lazy resolver recommended); overrides `store`/`tokenTtl` |
555
605
  | `getSubject` | `(c: Context) => string \| null` | `c.get('auth')?.userId ?? null` | extract subject on the token endpoint |
606
+ | `getKeyId` | `(c: Context) => string \| null \| undefined` | `c.get('auth')?.keyId` | the device key the token is tagged with; empty → untagged ([revoked keys](#closing-the-streams-of-a-revoked-device-key)) |
556
607
  | `authorize` | `(subject, events[]) => events[] \| Promise<…>` | — | subscription authorization (once on connect) |
557
608
  | `filter` | `{ [event]?: (subject, payload) => boolean }` | — | per-event payload filter |
558
609
  | `resolvePrincipal` | `(subject, request) => unknown \| Promise<unknown>` | — | principal for every `canSubscribe` (once per connection); a throw ⇒ `403` |
@@ -659,6 +710,13 @@ export const { GET, POST } = createRpcProxy({
659
710
  });
660
711
  ```
661
712
 
713
+ **A `401` from the token mint is final.** When `acquireToken` rejects with an error whose
714
+ `statusCode` (a registered class such as `UnauthorizedError`) or `status` (the api client's
715
+ `ApiError`, and `createAuthSSEClient`'s own error) is `401`, the client closes the
716
+ subscription and calls `onClose('unauthorized')` once, instead of reconnecting — the session
717
+ is gone (its device key was revoked), and retrying would mint and fail every `reconnectDelay`
718
+ forever. Any other rejection reconnects as before. `onClose` stays assignable from `() => void`.
719
+
662
720
  ### `subscribeToEvents(events, handlers, config?)` — one-shot helper
663
721
 
664
722
  ```typescript
@@ -742,7 +800,8 @@ payload can't serialize, the event is **still delivered to this pod's own subscr
742
800
  asymmetric case where the publish succeeds but **this pod's subscriber socket is down** (its
743
801
  echo — the only path to same-pod streams — wouldn't arrive). A sustained publish outage trips
744
802
  a short circuit breaker so emits fast-path to local instead of each paying the publish timeout.
745
- A publish never crashes `emit` or kills a stream. ioredis auto-reconnects and re-subscribes
803
+ A publish never crashes `emit` or kills a stream. Events are not re-sent; a key close is (see
804
+ `closeSSEConnectionsByKey`). ioredis auto-reconnects and re-subscribes
746
805
  (autoResubscribe, default true). A per-event SUBSCRIBE failure at
747
806
  startup degrades that event to in-process (logged) rather than aborting boot. Same-pod
748
807
  delivery costs one Redis round-trip — for a single-replica deploy with a cache, prefer
@@ -848,7 +907,8 @@ fully decoupled.
848
907
  this: on error with `acquireToken` set, it closes and reconnects through its own path to
849
908
  mint a fresh token. Don't disable `reconnect` expecting native retry to work with auth.
850
909
  - **Stream-time errors aren't subscription errors.** `authorize` returning `[]` ⇒ `403` at
851
- connect; invalid event names ⇒ `400`; missing/expired token ⇒ `401`. `onError` on the
910
+ connect; invalid event names ⇒ `400`; missing/expired token, or one whose key `verifyKey`
911
+ refuses ⇒ `401`. `onError` on the
852
912
  client fires for transport-level drops, not for these HTTP rejections — check the network
853
913
  response when a connection never opens.
854
914
  - **Subject default.** Without a custom `getSubject`, the token endpoint reads
@@ -608,7 +608,7 @@ export function Shell({ initial, workspaceId }: { initial: StateClientInitial<ty
608
608
  | `current(id, key?)` | Synchronous: `{ at, value, data?, status }`, `{ status: 'revoked' }` for a state revoked before any value arrived, or `undefined`. The same frozen object until a change is applied |
609
609
  | `connection()` | Synchronous: the connection status ([Connection status](#connection-status)), a frozen object, the same reference until it changes |
610
610
  | `subscribeConnection(listener)` | Registers `listener(status)`, called with the new status on every change, after `connection()` returns it; returns its unsubscribe (idempotent). The same function subscribed twice is two subscriptions |
611
- | `retry()` | When `'failed'` or `'reconnecting'`: reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
611
+ | `retry()` | When `'failed'`, `'reconnecting'` or `'closed'` (unauthorized — after the user signed in again): reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
612
612
  | `close()` | Closes the stream, drops every listener — connection listeners too — and stops listening to `online`; the status is back to `'reconnecting'` 0. Idempotent. A `subscribe` after it starts the client again, without `online`: `retry()` restarts it as before |
613
613
 
614
614
  `onError` receives a failure of something the client ran on its own — the first snapshot, the
@@ -695,6 +695,7 @@ first snapshot — so it says where they stand. `connection()` is one of:
695
695
  | `{ status: 'connected' }` | a stream is open (the SSE `onOpen`) |
696
696
  | `{ status: 'reconnecting', attempt }` | no stream open — not yet, or nothing subscribed — or the SSE client retrying a dropped one: `attempt` is its count (`onReconnect`), reset when a stream opens; `0` before the first stream opens, while the first snapshot is read or retried, and after `retry()` |
697
697
  | `{ status: 'failed' }` | the retries are exhausted: the SSE client's `maxReconnectAttempts` (its `onClose`, the attempts spent), or `maxRetries` for the first snapshot. Nothing more is tried until a restart (below) or, with the manifest unknown, a `get`, `write` or `snapshot` that brings it |
698
+ | `{ status: 'closed', reason: 'unauthorized' }` | the stream's token mint answered `401` (the SSE client's `onClose('unauthorized')`): the session is gone — its device key was revoked, e.g. "sign out this device" from another one. No retry is set, and neither `online` nor a `subscribe` reopens it; only the app's own `retry()`, once the user signed in again, does |
698
699
 
699
700
  - **Start value** `{ status: 'reconnecting', attempt: 0 }`, a client built with `initial` too:
700
701
  its entries are ready, its stream is not.
@@ -715,10 +716,17 @@ first snapshot — so it says where they stand. `connection()` is one of:
715
716
  - **`close()`** sets `'reconnecting'` 0, drops the connection listeners and removes the
716
717
  `online` listener; its own teardown is not `'failed'`. A `subscribe` after it starts the
717
718
  client again, and `retry()` restarts it; `online` is not listened to again.
719
+ - **`'closed'` / `'unauthorized'`** is not a network failure, so nothing restarts it on its
720
+ own: not `online`, not a `subscribe`. `retry()` reopens the stream (`'reconnecting'` 0); the
721
+ last unsubscribe resets the client to the start value, as from any status.
718
722
 
719
723
  ```tsx
720
724
  const connection = useConnectionStatus(state);
721
725
 
726
+ if (connection.status === 'closed')
727
+ {
728
+ return <Banner>You were signed out on this device.</Banner>; // connection.reason === 'unauthorized'
729
+ }
722
730
  if (connection.status === 'failed')
723
731
  {
724
732
  return <Banner action={() => state.retry()}>The server has a problem.</Banner>;