@lenso/realtime 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,421 @@
1
- # Temporary Holding Version
1
+ # @lenso/realtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Resource-authorized, bounded SSE subscriptions for ordinary async services and Fetch hosts.
4
+ This package is not a queue, a general event bus, an Agent token stream, or a WebSocket adapter.
5
+ Only `packages/realtime` is owned by this delivery. Core, Engine, Web, Manage, root configuration
6
+ and the shared lockfile are unchanged.
7
+
8
+ ## Minimal use
9
+
10
+ ```ts
11
+ import { createRealtime } from "@lenso/realtime";
12
+ import { createMemoryProvider } from "@lenso/realtime/memory";
13
+
14
+ // Execute during application setup, not when importing trusted configuration.
15
+ const realtime = await createRealtime({
16
+ provider: createMemoryProvider(),
17
+ async authorize(identity, resource, signal) {
18
+ // Host-owned policy must verify the opaque principal/session and load the
19
+ // actual resource. Do not treat subject/scope strings as proof of authority.
20
+ const allowed = await policy.canRead(identity.principal, resource, signal);
21
+ return allowed ? { validUntil: identity.expiresAt } : false;
22
+ },
23
+ });
24
+ // Register realtime.close() with the host immediately after acquiring it.
25
+ const connection = realtime.connect(verifiedIdentity, { signal: requestSignal });
26
+ const subscription = await connection.subscribe({
27
+ scope: verifiedIdentity.scope,
28
+ type: "note",
29
+ id: noteId,
30
+ });
31
+ const snapshot = await connection.snapshot(subscription, (signal) =>
32
+ notes.read(verifiedIdentity.principal, noteId, signal),
33
+ );
34
+ // snapshot is returned to the caller, NOT implicitly sent through SSE.
35
+ // If !snapshot.stable, refetch rather than treating it as an atomic snapshot.
36
+ const response = connection.response();
37
+
38
+ // Trusted business code only, after its write has committed:
39
+ await realtime.publish(subscription.resource, "note.updated", { revision: 2 });
40
+ subscription.unsubscribe();
41
+ connection.close();
42
+ await realtime.close();
43
+ ```
44
+
45
+ No authentication, User table, Organization dependency, HTTP publisher or automatic
46
+ operation exposure is introduced. Keep the instance/publisher in trusted service setup;
47
+ give entries a connection, not a publishing capability.
48
+
49
+ **Identity:** `{scope, subject, principal, expiresAt}` comes from a verified host entry.
50
+ `principal` is opaque host/Auth evidence, not business JSON. `subject` is an admission key,
51
+ not an authorization grant. The host defines `scope`: a single application, tenant,
52
+ workspace or another domain. **Resource:** `{scope, type, id}` is resolved by the server
53
+ from the actual record. A mismatch with the identity scope is rejected before authorization.
54
+ Each component is nonempty, at most 256 UTF-8 bytes, without Unicode normalization.
55
+ Topics are collision-free percent-encoded server constructions, never client channel names.
56
+ Provider access is privileged infrastructure access, not a tenant boundary.
57
+
58
+ **Subscription:** an opaque handle with `id`, immutable `resource`, `unsubscribe()` and
59
+ `renew()`. Another connection cannot use its handle for snapshot access. Reservations
60
+ count toward limits before async authorization; cancellation/revocation cannot resurrect
61
+ a late grant. A connection exposes subscribe/snapshot/iterate/response/close, not publish.
62
+ Use either one iterator or one Response per connection, not both.
63
+
64
+ ## Authorization and lifetime
65
+
66
+ - The business authorizer runs at subscription and explicit renewal only. It returns
67
+ `false` or an absolute `validUntil` timestamp. There is no per-message DB query.
68
+ - A grant expires at the earliest of the policy deadline, identity/session expiry,
69
+ and authorization **start time** + `authorizationLeaseMs`. The maximum is 30 seconds,
70
+ including time spent waiting for authorization. Slow grants cannot extend that bound.
71
+ - Hosts with ongoing streams call `handle.renew()` before expiry, using the same
72
+ revalidating business policy. Renewal does not renew the session. Without renewal,
73
+ the handle closes. A raw EventSource bridge without a renewal endpoint may simply
74
+ close/reconnect and reauthorize every lease; do not silently lengthen leases.
75
+ - After committing an ACL change or deletion, call `revokeResource()` or `deleteResource()`.
76
+ They immediately purge/abort local handles, then publish control to other instances.
77
+ Remote delivery is best effort; a lost notice is bounded by the lease. Persisted
78
+ policy must reject new subscriptions/renewals. Notices are not durable ACL storage.
79
+ - `revokeSubject(scope, subject)` is a local fast path for logout/session revocation.
80
+ It does not claim distributed session cancellation. Other instances must revalidate
81
+ the revoked session at renewal and stop no later than their last grant's deadline.
82
+ - **Revocation bound:** no new package delivery/dequeue after the grant deadline;
83
+ idle subscription cleanup within deadline + `sweepMs` (default 250 ms, maximum 1 s),
84
+ assuming a running event loop and correct policy. Thus at most 30 s of authorization
85
+ and 31 s to reclaim an idle handle. Already emitted bytes cannot be recalled.
86
+ - `AbortSignal`, body cancellation, iterator return, unsubscribe, session expiry,
87
+ resource revocation/deletion, provider failure and host close remove subscriptions,
88
+ abort pending work, purge private queued events and settle blocked readers.
89
+ Only fixed lifecycle diagnostics are emitted; no token, identity, private topic,
90
+ backend exception or business payload is logged.
91
+ - Authorization/snapshot callbacks must cooperate with their signal and release their
92
+ own resources. Their default deadline is 5 s. Timed-out underlying work retains a
93
+ pending slot until it settles; the package cannot forcibly stop arbitrary JS/DB work.
94
+ Shutdown aborts it but does not wait forever for an uncooperative callback.
95
+
96
+ ## Wire contract and reconnect
97
+
98
+ Each UTF-8 SSE frame uses `event: <kind>`, optional `id: <cursor>`, and one JSON `data`
99
+ line containing:
100
+
101
+ ```ts
102
+ {
103
+ version: 1,
104
+ kind: "ready" | "update" | "gap" | "closed" | "heartbeat",
105
+ subscription?: string,
106
+ cursor?: string,
107
+ type?: string, // business event name, e.g. note.updated
108
+ data?: Json, // update invalidation metadata
109
+ reason?: string
110
+ }
111
+ ```
112
+
113
+ `ready` means authorized subscription and provider watermark established; it requires
114
+ a fresh authoritative snapshot, not that the application has received one. `update`
115
+ is an **invalidation hint**, not an ordered patch to apply blindly. `closed` names
116
+ `revoked`, `deleted`, `expired`, `shutdown` or `unsubscribed`. A connection-level terminal
117
+ `gap` names `overflow` or `provider`, and is followed by EOF. Per-handle gaps name
118
+ `sequence`, `generation`, `out-of-order`, `snapshot-race`, `reconnect` or `cursor-expired`.
119
+ The client closes its EventSource when intentionally unsubscribing or permanently denied;
120
+ native EventSource otherwise retries EOF. The first frame supplies configurable `retry`.
121
+ Custom clients should use jittered exponential retry, starting at `retryMs`, capped by
122
+ their host policy; the server/provider never automatically retry ambiguous publishes.
123
+
124
+ Payloads must be acyclic, plain finite JSON. No BigInt, undefined, Date/classes, accessors,
125
+ symbols, serialization hooks, non-enumerable properties, sparse/custom-property arrays,
126
+ NaN or Infinity. Event structure is limited to 32 levels and 10,000 visited values.
127
+ `maxPayloadBytes` bounds the serialized **ProviderEvent** (including kind/type), default
128
+ 16 KiB, hard maximum 32 KiB. SSE metadata adds at most a 512-byte envelope budget and
129
+ a small framing overhead, below Web's default 64 KiB chunk budget. Provider wire limit
130
+ is 48 KiB. Mismatched publisher/receiver payload configurations fail the receiving
131
+ instance closed with a provider gap; use the same limits across the deployment.
132
+
133
+ **Cursor:** opaque `generation.sequence.issuedAt` detection token. Sequence is a
134
+ nonnegative safe integer, ordered only inside one resource/generation; generation is
135
+ reset on watermark expiry/provider state loss. Cursor age defaults to 10 minutes.
136
+ `issuedAt` is the observation/token issuance time, not the publication time. Different
137
+ instances observing the same generation/sequence may mint different token strings;
138
+ the watermark identity is generation + sequence, not string equality across instances.
139
+ Malformed, future-dated or older reconnect tokens produce `cursor-expired`. All other
140
+ reconnect tokens produce `reconnect`, even if they equal the current cursor.
141
+ Tokens confer no authorization and are never accepted as read positions.
142
+ There is **no persistent event log, event retention or replay**. No exactly-once claim.
143
+
144
+ Client consistency rules:
145
+
146
+ 1. Subscribe first. The package's `snapshot(handle, read)` samples provider cursors
147
+ before/after an authorized business read. Changed cursor => `stable:false` and
148
+ `snapshot-race` gap. Refetch. Revoke/expiry during a read prevents its value returning.
149
+ 2. For an ordinary HTTP snapshot endpoint, start consuming SSE first, mark dirty on
150
+ every update/gap while the snapshot request is in flight, and refetch if dirty.
151
+ Continue treating future updates as invalidations. Never fetch first and subscribe later.
152
+ 3. On every reconnect, generation change, sequence gap, overflow, stale cursor or
153
+ provider replacement: discard assumptions about incremental state and obtain a snapshot.
154
+ Equal duplicate cursors are suppressed; older sequence events cause gap and are dropped.
155
+ 4. `stable` means no **published** change during that read, not a transaction spanning
156
+ the business DB and Redis. A write committed before delayed publication is eventually
157
+ invalidated when its event arrives. Failed/missing publication cannot be inferred from
158
+ the cursor. Hosts must preserve publish-after-commit ordering; if their product needs
159
+ atomic reliable publication, an owned outbox/event log is a separate integration,
160
+ not implemented here. Do not retry an already committed business write on publish failure.
161
+
162
+ ## Configuration and limits
163
+
164
+ `resolveRealtimeConfig` is the same validator used by `/plugin` and direct async setup.
165
+ `createRealtimePlugin({id, requires, config, provider, authorize})` uses existing Core
166
+ `bindConfig`/`definePluginConfig`; config accepts values or ordered Core sources.
167
+ The provider factory executes in setup, not at config import. Cleanup is registered
168
+ before the next fallible step. Provider selection is host configuration:
169
+
170
+ ```ts
171
+ provider: () =>
172
+ settings.provider === "redis"
173
+ ? createRedisProvider({ url: bindings.REDIS_URL, namespace: settings.namespace })
174
+ : createMemoryProvider();
175
+ ```
176
+
177
+ Import `/redis` only in a Bun host; Redis URL/credentials belong in trusted runtime
178
+ bindings, never manifests, JSON operations, logs or committed configuration. No new
179
+ environment/configuration system is created.
180
+
181
+ | Instance-local limit | Default |
182
+ | ------------------------------------------------------- | -----------------: |
183
+ | Connections / connections per scoped subject | 1,000 / 8 |
184
+ | Subscriptions per connection / per scoped subject | 16 / 64 |
185
+ | Subscribers per topic / active topics | 1,000 / 1,000 |
186
+ | Pending service operations | 64 |
187
+ | Buffered events / encoded envelope bytes per connection | 64 / 131,072 |
188
+ | ProviderEvent payload bytes | 16,384 |
189
+ | Authorization lease / sweep interval | 30,000 / 250 ms |
190
+ | Heartbeat / SSE reconnect starting delay | 15,000 / 2,000 ms |
191
+ | Cursor maximum age / callback timeout | 600,000 / 5,000 ms |
192
+
193
+ Limits are positive safe integers; unknown keys are rejected. `sweepMs` must not exceed
194
+ the lease. Heartbeat and retry intervals are configurable. Byte budget must be at least
195
+ payload limit + 512 bytes. One instance timer handles all leases and heartbeats.
196
+ Slow consumers never block publish: when either queue budget is exceeded, discard the
197
+ queue, retain one small terminal overflow gap, remove all references and disconnect.
198
+ Host/proxy/socket buffers are outside this package; configure their own limits and idle
199
+ deadlines. This package's admission counts are not distributed rate limits; a multi-node
200
+ deployment still needs host ingress/subject abuse controls.
201
+
202
+ ## Providers and host matrix
203
+
204
+ **Memory:** independent per-provider Map, no global shared singleton and no cross-process
205
+ broadcast. Maximum 10,000 watermarks; inactivity TTL 10 minutes. Expired keys are reclaimed
206
+ on admission, or all on close; the map is always bounded. A current call/publication
207
+ refreshes metadata TTL. New provider/expired watermark means new generation. Delivery is
208
+ synchronous, once per active service callback in normal operation, with no persistence.
209
+
210
+ **Redis:** chosen instead of PostgreSQL LISTEN/NOTIFY or DO because standalone Redis
211
+ and Bun are available for real local testing; native Pub/Sub supplies broadcast, not
212
+ competing consumption. No new client dependency or lockfile change.
213
+
214
+ - One dedicated namespace subscription socket plus one command socket **per instance**,
215
+ independent of browser connection count. All active topics share the subscription;
216
+ each instance receives its namespace's messages and filters locally. Trusted instances
217
+ sharing a namespace can see all its provider payloads, so scope is not a Redis ACL.
218
+ - Subscribe ACK before start resolves. Atomic Lua increments the per-topic watermark
219
+ and publishes one delivery. The originating instance also receives it through Pub/Sub;
220
+ no optimistic local update is duplicated.
221
+ - Standalone Redis endpoint only. No verified Redis Cluster, Sentinel failover, sharded
222
+ topology or load-balanced Pub/Sub. Configure the same endpoint/namespace on all nodes.
223
+ A Redis ACL must allow connection/subscription, EVAL, EXISTS/HSET/HGET/HINCRBY/PEXPIRE
224
+ and PUBLISH. Use the host's Redis security/TLS/network owner, not client-side credentials.
225
+ - Normal connected delivery is ephemeral, best effort/at-most-once transport. Per-topic
226
+ Lua sequence gives order at Redis; service detects duplicate/out-of-order delivery
227
+ without promising exactly-once application effects.
228
+ - No offline queue or automatic reconnect. Any socket loss, command error/deadline or
229
+ malformed wire is terminal, closes both sockets and notifies once. Replace the failed
230
+ instance/provider, reconnect and snapshot. A failed/timed-out publish has unknown outcome;
231
+ no retry or rollback. No events survive for a disconnected subscriber.
232
+ - Expiring metadata only, **not retained events**. `watermarkTtlMs` 600,000,
233
+ `commandTimeoutMs` 5,000, `connectionTimeoutMs` 2,000, `maxPendingOperations` 64.
234
+ Trusted publication controls the global TTL key cardinality; active-topic admission
235
+ is instance-local. Production Redis memory/traffic quotas remain deployment-owned.
236
+
237
+ | Host/path | Memory | Redis | Validation |
238
+ | --------------------------------- | ---------------------------- | ---------------------------- | --------------------------------------------------------- |
239
+ | Bun Fetch + Web raw hook | Single long-lived instance | Multi-instance | Real HTTP SSE/cancel; real Redis subprocesses |
240
+ | oRPC 2.0.0-beta.42 async iterator | Same service/iterator | Same service | Existing Web path consumed/cancelled |
241
+ | Workers Fetch adapter | Request-local lifecycle only | Unsupported Bun entry | Local adapter test/browser bundle, no platform deployment |
242
+ | Other Fetch hosts (Node/Deno) | Portable primitives | Unsupported native Bun entry | Browser bundle only; no Node/Deno runtime claim |
243
+
244
+ Workers currently assemble one application per request. A per-request memory provider
245
+ does **not** broadcast to another request/isolate; no useful distributed Workers provider
246
+ is delivered. No upgrade/WebSocket capability or DO binding/deployment is invented.
247
+ No PostgreSQL LISTEN connection or platform portability claim is made.
248
+
249
+ Web's raw hook can select an explicitly authorized resource route, obtain the exact
250
+ Realtime plugin instance, connect with `WebContext.signal`, register connection cleanup
251
+ with `WebContext.onCleanup`, subscribe, and return `connection.response()`. Returning
252
+ undefined leaves existing oRPC routes unchanged. Raw routes must independently enforce
253
+ Auth, method, Origin/Host and input policy. Web deadlines cover the entire SSE body;
254
+ omit `timeoutMs` for a long stream or intentionally allow expiry/reconnect. Do not put
255
+ a never-ending producer into `waitUntil` if it can only settle during cleanup.
256
+
257
+ oRPC already consumes an async generator: `try { for await (const event of connection)
258
+ yield event; } finally { connection.close(); }`, with its procedure signal and existing
259
+ Auth middleware. Its wire format is oRPC's format, not this package's raw SSE framing;
260
+ use its client and optionally `withEventMeta` for IDs/retry. No Web refactor is needed.
261
+
262
+ ## Notes and Tasks
263
+
264
+ `examples/notes.ts` bridges an authorized ordinary async `NotesPort` into subscribe-first
265
+ snapshot and publish-after-update invalidation. The existing Notes service can implement
266
+ that port with its read/update audience-specific Auth actors; the port does not fabricate
267
+ an Auth actor from a subject string. No dependency on another parallel package is required.
268
+ `examples/run-notes.ts` is a runnable trusted in-process Notes fixture with two readers,
269
+ not a demo app or production login. `examples/tasks.ts` is a thin post-commit status hook;
270
+ it neither polls Tasks nor changes their queue contract.
271
+
272
+ ```sh
273
+ bun run --cwd packages/realtime build
274
+ bun packages/realtime/examples/run-notes.ts
275
+ ```
276
+
277
+ ## Integration owner handoff
278
+
279
+ 1. Landing integrates this manifest into the shared Bun lockfile and records a package
280
+ changeset. Both candidate and clean-source frozen installs are verified without changing
281
+ the lock hash or existing dependency resolutions. CI and release verification install
282
+ Redis binaries so the real provider tests run. These preparation steps do not publish
283
+ the package, prepare release versions or authorize deployment.
284
+ 2. Select the provider factory/runtime bindings and existing Core config sources in the
285
+ application, with exact `requires` references for Web/business dependencies. No new
286
+ Core/Engine interface is needed.
287
+ 3. Add application-owned raw or oRPC authorized ingress, snapshot transport and lease
288
+ renewal policy. Hook committed Notes updates/deletes and ACL/session invalidation into
289
+ the trusted service. Existing Auth must revalidate session and resource policy.
290
+ 4. Manage can explicitly select finite `stats` or host-owned revoke operations via existing
291
+ Operations/Manage declarations, audience binding and approval policy. This package
292
+ intentionally exposes no stream, publisher, principal-bearing method or revoke route
293
+ automatically. No Manage/core contribution or registry patch is necessary.
294
+ 5. Configure proxy buffering/idle timeouts, ingress and distributed abuse limits. Validate
295
+ production Redis topology/TLS/ACL and any Workers bindings separately. For atomic
296
+ durable replay/publication, propose an owned event log/outbox with retention and recovery
297
+ tests before changing these semantics.
298
+
299
+ ## Verification evidence
300
+
301
+ The implementation measurements and checks below are local, not deployment/release approval. Environment:
302
+ Bun 1.4.2, macOS 27.0.1 / Darwin 27.0.0 arm64, Apple M2 Pro, Redis 8.10.2.
303
+ Tests start owned loopback Redis processes with persistence disabled and isolated temporary
304
+ directories, then stop/remove only those owned resources. If `redis-server` is absent,
305
+ integration explicitly skips and prints the missing executable; no simulated Redis pass.
306
+
307
+ | Command/check | Final result |
308
+ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
309
+ | `bun run --cwd packages/realtime build` | Passed, portable root/memory/plugin and Bun Redis JS + declarations |
310
+ | `bun run --cwd packages/realtime typecheck` | Passed, source/tests/integration/examples/measurement |
311
+ | `bun run --cwd packages/realtime test` | 50 passed, 0 failed, 303 assertions; Bun's `test` filter also includes Redis integration |
312
+ | `bun run --cwd packages/realtime test:redis` | 12 passed, 0 failed, 180 assertions |
313
+ | `bun packages/realtime/examples/run-notes.ts` | Snapshot + the same note revision delivered to two readers |
314
+ | `node_modules/.bin/oxlint packages/realtime --deny-warnings` | 0 errors, 0 warnings |
315
+ | `node_modules/.bin/oxfmt --check packages/realtime` | Passed |
316
+ | `bun run --cwd packages/realtime measure` | Passed with the measurements below |
317
+ | `cd packages/realtime && bun pm pack --destination "$DELTA_SCRATCH_DIR"` | Local tarball produced, 18 files; no publishing |
318
+ | Extracted tarball smoke | All four public JS/type export targets exist; imports, memory subscription/publication and packaged Notes example passed |
319
+
320
+ Packed `/plugin` smoke borrowed the already built local Core optional peer through a
321
+ temporary symlink. This is a local artifact/peer compatibility check, **not a registry
322
+ installation or release verification**. No package was uploaded. Examples import public
323
+ package entries and work without packing `src`.
324
+
325
+ Dependency builds actually run:
326
+ `bun run --cwd packages/lenso build`, `bun run --cwd packages/web build`,
327
+ `bun run --cwd packages/workers build`, `bun run --cwd packages/log build`,
328
+ `bun run --cwd packages/otel build`. All passed.
329
+
330
+ Affected host checks:
331
+
332
+ ```sh
333
+ bun test packages/web/test/stream.test.ts \
334
+ packages/web/test/openapi-stream.test.ts packages/web/test/bun.test.ts \
335
+ packages/web/test/http.test.ts packages/workers/test/fetch.test.ts \
336
+ packages/workers/test/rpc.test.ts packages/workers/test/config.test.ts
337
+ bun run --cwd packages/web typecheck
338
+ bun run --cwd packages/workers typecheck
339
+ ```
340
+
341
+ 40 host tests passed, 0 failed, 205 assertions; both host typechecks passed. The first Web
342
+ typecheck failed because `@lenso/otel/bun` declarations had not been built; it passed after
343
+ the Log/Otel prerequisite builds. Initial Realtime adapter tests exposed the oRPC initial
344
+ comment frame and incorrect test cleanup registration; final tests consume the actual wire
345
+ and use request cleanup correctly. Review caught and regression-tested near-node-limit
346
+ envelope serialization, deferred local control delivery during snapshots, and hidden
347
+ serialization hooks. All final checks above include those fixes.
348
+
349
+ Coverage includes allow/deny, cross-scope rejection/isolation, resource/session/subject
350
+ revocation, lease renewal/expiry and slow grants, two-reader fan-out, unsubscribe/abort/body
351
+ cancel/iterator return/host stop, bounded event/byte overflow, JSON/size limits, reconnect and
352
+ expired cursor, subscribe-first snapshot races and revocation during snapshots, provider
353
+ failure/startup rollback, duplicate/sequence/out-of-order/generation changes, pending
354
+ operation saturation, heartbeat/retry, and per-subject/topic/instance admission.
355
+ Redis evidence includes two distinct child PIDs receiving identical ordered provider
356
+ deliveries/cursors, plus two independently constructed Realtime instances receiving updates
357
+ and revocation through Redis. No shared in-process Map substitutes for Redis.
358
+ Provider restart recovery is a new instance + snapshot, not replay: server termination,
359
+ fresh/expired generations, and absence of late-listener history are tested independently.
360
+
361
+ ### Bounded measurements
362
+
363
+ Command: `bun run --cwd packages/realtime measure` (sets
364
+ `BUN_CONFIG_MAX_HTTP_REQUESTS=1024`). One process sequentially tests 100 and 500 real
365
+ loopback Fetch/SSE connections, admitted in batches of 50, all simultaneously open for
366
+ measurement. One resource, one subscription and distinct subject per connection;
367
+ 20 publications per scale, each with 256 ASCII payload-text bytes plus event metadata.
368
+ Clients await every read before the next publish. Timing is publish-start to each client's
369
+ read completion, including serialization, fan-out and loopback HTTP; no TLS/proxy/Redis.
370
+ Memory uses `process.memoryUsage()` after `Bun.gc(true)` before/after admission.
371
+
372
+ | Connections | Deliveries | Setup ms | p50 ms | p95 ms | Max ms | Baseline RSS bytes | Active RSS bytes | Baseline JS heap bytes | Active JS heap bytes |
373
+ | ----------: | ---------: | -------: | -----: | -----: | -----: | -----------------: | ---------------: | ---------------------: | -------------------: |
374
+ | 100 | 2,000 | 11.31 | 0.665 | 1.088 | 1.535 | 16,400,384 | 35,438,592 | 263,179 | 27,424,154 |
375
+ | 500 | 10,000 | 37.48 | 3.381 | 3.889 | 4.541 | 55,066,624 | 69,337,088 | 933,108 | 134,957,872 |
376
+
377
+ The second scale retains runtime allocations from the first; these deltas are not
378
+ per-connection estimates. Bun's JS heap accounting can exceed resident RSS; report the
379
+ two separately rather than treating either as allocated physical memory.
380
+
381
+ Slow-consumer run: 500 unread async iterables, one topic, 32-event cap including ready,
382
+ 31 updates of 256 payload-text bytes fill queues. Full-GC JS heap rises from 2,686,292
383
+ to 10,538,758 bytes (queue delta **7,852,466 bytes**); RSS from 135,479,296 to 137,084,928.
384
+ The next publication produces **500 overflow gaps**, then connections/subscriptions/topics
385
+ are all **0**. It exercises the package buffer, not an arbitrary proxy/network send buffer.
386
+
387
+ The first benchmark attempt timed out at 120 s with default HTTP client concurrency;
388
+ another burst-admission attempt encountered ECONNRESET. The final runner sets explicit
389
+ client concurrency and batches admission rather than hiding these failures or retrying
390
+ missed updates. The final numbers above come from one complete successful final run.
391
+ They are a bounded local measurement, not production capacity/latency guarantees.
392
+
393
+ At implementation handoff, not run: root-wide build/typecheck/test/release verification, registry installation,
394
+ production deployment, Redis TLS/ACL/Cluster/Sentinel/proxy failure tests, sustained
395
+ distributed throughput/memory load, Node/Deno execution, or deployed Workers/DO runtime.
396
+ There is no persistent replay recovery test because no replay is promised or implemented.
397
+ Application Auth/ingress wiring remains integration-owner work; the package and examples
398
+ themselves are runnable and locally packed.
399
+
400
+ ### Landing integration verification
401
+
402
+ The landing candidate adds the root workspace lock entry and manifest-matching peer
403
+ metadata, preserving every existing external dependency resolution. It also records a
404
+ Realtime changeset and installs Redis alongside PostgreSQL in Checks/release verification.
405
+ `bun install --frozen-lockfile` passes both in the candidate and in an archived clean
406
+ source copy without `node_modules` or `dist`; both preserve the lock hash.
407
+
408
+ `bash scripts/ci-checks.sh` passes locally, including root lint, format, build, typecheck,
409
+ test, release-script tests and the script's independently owned disposable PostgreSQL
410
+ fixtures. Realtime has 50 passing tests with all 12 real Redis integration cases executed.
411
+ No shared database or connection-string credential is used for those fixtures.
412
+
413
+ Local Core and Realtime archives are installed together in a standalone temporary consumer,
414
+ without workspace symlinks. TypeScript checks and runtime checks pass for all public imports,
415
+ Core plugin setup, subscribe/snapshot/publish/unsubscribe/cleanup, and the packaged Notes
416
+ example. This is not a registry installation or package publication.
417
+
418
+ The same bounded measurement command passes again during landing: 100/500 loopback SSE
419
+ connections have p95 1.272/4.018 ms respectively; the 500 unread connections again produce
420
+ 500 overflow gaps and release all subscriptions. This second local sample does not replace
421
+ the implementation sample or extend its production/host guarantees.
@@ -0,0 +1,3 @@
1
+ import { type RealtimeConfig } from "./contracts";
2
+ export declare const defaults: Required<RealtimeConfig>;
3
+ export declare function resolveRealtimeConfig(input?: RealtimeConfig): Required<RealtimeConfig>;
@@ -0,0 +1,119 @@
1
+ export type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ /** Scope is host-defined: an application, tenant, workspace or another isolation domain. */
5
+ export interface Resource {
6
+ readonly scope: string;
7
+ readonly type: string;
8
+ readonly id: string;
9
+ }
10
+ export interface Identity<P = unknown> {
11
+ readonly scope: string;
12
+ readonly subject: string;
13
+ readonly principal: P;
14
+ readonly expiresAt: number;
15
+ }
16
+ export interface Cursor {
17
+ readonly generation: string;
18
+ readonly sequence: number;
19
+ }
20
+ export type ProviderEvent = {
21
+ readonly kind: "update";
22
+ readonly type: string;
23
+ readonly data: Json;
24
+ } | {
25
+ readonly kind: "deleted";
26
+ } | {
27
+ readonly kind: "revoke";
28
+ };
29
+ export interface Delivery {
30
+ readonly topic: string;
31
+ readonly cursor: Cursor;
32
+ readonly event: ProviderEvent;
33
+ }
34
+ /** A provider belongs to one Realtime instance. start subscribes before resolving. */
35
+ export interface RealtimeProvider {
36
+ readonly kind: "memory" | "redis";
37
+ start(deliver: (delivery: Delivery) => void, fail: () => void): Promise<void>;
38
+ current(topic: string): Promise<Cursor>;
39
+ publish(topic: string, event: ProviderEvent): Promise<Cursor>;
40
+ close(): Promise<void>;
41
+ }
42
+ export type ErrorCode = "invalid-input" | "denied" | "expired" | "limit" | "closed" | "provider" | "payload";
43
+ export declare class RealtimeError extends Error {
44
+ readonly code: ErrorCode;
45
+ constructor(code: ErrorCode);
46
+ }
47
+ export type GapReason = "reconnect" | "cursor-expired" | "sequence" | "generation" | "out-of-order" | "snapshot-race" | "overflow" | "provider";
48
+ export interface Envelope {
49
+ readonly version: 1;
50
+ readonly kind: "ready" | "update" | "gap" | "closed" | "heartbeat";
51
+ readonly subscription?: string;
52
+ readonly cursor?: string;
53
+ readonly type?: string;
54
+ readonly data?: Json;
55
+ readonly reason?: GapReason | "revoked" | "deleted" | "expired" | "shutdown" | "unsubscribed";
56
+ }
57
+ export interface RealtimeConfig {
58
+ readonly maxConnections?: number;
59
+ readonly maxConnectionsPerSubject?: number;
60
+ readonly maxSubscriptionsPerConnection?: number;
61
+ readonly maxSubscriptionsPerSubject?: number;
62
+ readonly maxSubscribersPerTopic?: number;
63
+ readonly maxTopics?: number;
64
+ readonly maxPendingOperations?: number;
65
+ readonly maxPayloadBytes?: number;
66
+ readonly maxBufferedEvents?: number;
67
+ readonly maxBufferedBytes?: number;
68
+ readonly authorizationLeaseMs?: number;
69
+ readonly sweepMs?: number;
70
+ readonly heartbeatMs?: number;
71
+ readonly retryMs?: number;
72
+ readonly cursorMaxAgeMs?: number;
73
+ readonly snapshotTimeoutMs?: number;
74
+ }
75
+ export interface RealtimeOptions<P> {
76
+ readonly provider: RealtimeProvider;
77
+ readonly config?: RealtimeConfig;
78
+ /** Called only on subscribe/explicit renewal, never once per event. */
79
+ readonly authorize: (identity: Identity<P>, resource: Resource, signal: AbortSignal) => Promise<{
80
+ validUntil: number;
81
+ } | false>;
82
+ /** Only fixed categories, never identity, topics, credentials, errors or payloads. */
83
+ readonly onDiagnostic?: (event: "overflow" | "provider" | "authorization") => void;
84
+ }
85
+ export interface Subscription {
86
+ readonly id: string;
87
+ readonly resource: Resource;
88
+ unsubscribe(): void;
89
+ renew(): Promise<void>;
90
+ }
91
+ export interface RealtimeConnection extends AsyncIterable<Envelope> {
92
+ subscribe(resource: Resource, options?: {
93
+ cursor?: string;
94
+ }): Promise<Subscription>;
95
+ snapshot<T>(subscription: Subscription, read: (signal: AbortSignal) => Promise<T>): Promise<{
96
+ value: T;
97
+ cursor: string;
98
+ stable: boolean;
99
+ }>;
100
+ response(): Response;
101
+ close(): void;
102
+ }
103
+ export interface Realtime<P = unknown> {
104
+ connect(identity: Identity<P>, options?: {
105
+ signal?: AbortSignal;
106
+ }): RealtimeConnection;
107
+ publish(resource: Resource, type: string, data: Json): Promise<string>;
108
+ revokeResource(resource: Resource): Promise<void>;
109
+ deleteResource(resource: Resource): Promise<void>;
110
+ /** Local fast path. Other instances still enforce the bounded lease. */
111
+ revokeSubject(scope: string, subject: string): void;
112
+ stats(): {
113
+ connections: number;
114
+ subscriptions: number;
115
+ topics: number;
116
+ pending: number;
117
+ };
118
+ close(): Promise<void>;
119
+ }