@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/README.md +1 -1
- package/dist/db/index.d.ts +254 -84
- package/dist/db/index.js +400 -25
- package/dist/db/index.js.map +1 -1
- package/dist/event/index.d.ts +2 -2
- package/dist/event/index.js +32 -4
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +12 -4
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +40 -5
- package/dist/event/sse/index.js +437 -28
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +223 -37
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +8 -1
- package/dist/state/client.js +15 -5
- package/dist/state/client.js.map +1 -1
- package/dist/state/react.d.ts +3 -1
- package/dist/state/react.js.map +1 -1
- package/dist/{token-manager-Ckjt2-qq.d.ts → token-manager-CvOsAbnK.d.ts} +47 -3
- package/dist/{types-CurMab2Q.d.ts → types-BrO7j6mR.d.ts} +17 -3
- package/dist/{types-DncqXfWN.d.ts → types-DH6d2eOb.d.ts} +1 -1
- package/package.json +1 -1
- package/src/db/README.md +109 -6
- package/src/db/schema/README.md +19 -1
- package/src/event/README.md +66 -6
- package/src/state/README.md +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
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), `
|
|
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,
|
package/src/db/schema/README.md
CHANGED
|
@@ -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
|
package/src/event/README.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
│ ───────────────────────────────► │
|
|
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.
|
|
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
|
|
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
|
package/src/state/README.md
CHANGED
|
@@ -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 `'
|
|
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>;
|