@ultimat3/realtime 24.0.0 → 25.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/page-store.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
// The page's realtime state
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The page's realtime state as every island READS it: one object per tab on `globalThis` under a
|
|
2
|
+
// `Symbol.for` key, because every island is its own bundle and a module-scope singleton here is
|
|
3
|
+
// one store PER ISLAND. Thin by design — the store, the socket and the transport behind it are the
|
|
4
|
+
// page runtime's (`page-runtime.ts`), shipped once per page and never inside an island (#505).
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import type { clientTransport } from '@ultimat3/core/page';
|
|
7
|
+
import { CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
8
|
+
import type { queryClientMethodFor } from '@ultimat3/query/client';
|
|
9
|
+
import type { LiveClient } from './client';
|
|
10
|
+
import { RealtimeUninstalledError } from './page-errors';
|
|
11
|
+
import type { RecordStore } from './record-store';
|
|
7
12
|
|
|
8
13
|
/** Where the page's one socket dials. Resolved on the server and handed to the island bootstrap. */
|
|
9
14
|
export interface SyncTarget {
|
|
@@ -15,7 +20,12 @@ export interface SyncTarget {
|
|
|
15
20
|
|
|
16
21
|
/** Page-wide and shared by every bundle. Structural on purpose: no `instanceof` across copies. */
|
|
17
22
|
export interface PageRealtime {
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The page's ONE record store — `undefined` until the page runtime is installed (the page boot,
|
|
25
|
+
* or the runtime chunk an island loads where no boot is rendered). A hook reads it through
|
|
26
|
+
* `installedPage`, which refuses by code rather than answering a store nobody shares.
|
|
27
|
+
*/
|
|
28
|
+
store: RecordStore | undefined;
|
|
19
29
|
sync: SyncTarget | undefined;
|
|
20
30
|
/** The socket client, once a live hook opened it. Typed by `page-socket.ts`, its one writer. */
|
|
21
31
|
socket: unknown;
|
|
@@ -27,8 +37,30 @@ export interface PageRealtime {
|
|
|
27
37
|
* the first frame can race it. Resolved at once on a page that carries no boot script.
|
|
28
38
|
*/
|
|
29
39
|
readonly booted: Promise<void>;
|
|
40
|
+
/** What a hook calls instead of bundling it — installed with the store, by the page runtime. */
|
|
41
|
+
services: PageServices | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The runtime's half of every hook: the work whose code would otherwise ship in each island. An
|
|
46
|
+
* island calls these off the page object, so the socket stack, the query client and core's
|
|
47
|
+
* transport are in the page runtime once, however many islands the page renders.
|
|
48
|
+
*/
|
|
49
|
+
export interface PageServices {
|
|
50
|
+
/** The page's one socket, opened on the first ask. `hook` names the caller in a refusal. */
|
|
51
|
+
socket(hook: string): LiveClient;
|
|
52
|
+
/** A non-live query read over HTTP — `@ultimat3/query`'s client, through core's transport. */
|
|
53
|
+
read(name: string): ReturnType<typeof queryClientMethodFor>;
|
|
54
|
+
/** Core's one transport, for a write's POST. */
|
|
55
|
+
send: typeof clientTransport;
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
/** The page once its runtime is installed: what every hook works against. */
|
|
59
|
+
export type InstalledPage = PageRealtime & {
|
|
60
|
+
readonly store: RecordStore;
|
|
61
|
+
readonly services: PageServices;
|
|
62
|
+
};
|
|
63
|
+
|
|
32
64
|
/** Every optimistic write on the page, counted per mutator name, and who renders the counts. */
|
|
33
65
|
export interface PageWrites {
|
|
34
66
|
readonly pending: Map<string, number>;
|
|
@@ -86,9 +118,32 @@ export function bootedPromise(): Promise<void> {
|
|
|
86
118
|
return waiting;
|
|
87
119
|
}
|
|
88
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Whether a deferred `<script>` of this document may still run. Not past `loading`, yes; past
|
|
123
|
+
* `complete`, no. In between (`interactive`) the deferred scripts run, then DOMContentLoaded fires —
|
|
124
|
+
* and `readyState` stays `interactive` until every image has loaded, so the navigation timing's
|
|
125
|
+
* `domContentLoadedEventStart` is what says they have all run (a boot that 404'd among them). A
|
|
126
|
+
* document with no such reading is assumed still running them: a caller then waits on DOMContentLoaded.
|
|
127
|
+
*/
|
|
128
|
+
export function deferredScriptsPending(): boolean {
|
|
129
|
+
if (typeof document === 'undefined') return false;
|
|
130
|
+
const state: unknown = document.readyState;
|
|
131
|
+
if (state === 'complete') return false;
|
|
132
|
+
if (state !== 'interactive') return true;
|
|
133
|
+
const timing: unknown =
|
|
134
|
+
typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function'
|
|
135
|
+
? undefined
|
|
136
|
+
: performance.getEntriesByType('navigation')[0];
|
|
137
|
+
const started: unknown =
|
|
138
|
+
typeof timing === 'object' && timing !== null
|
|
139
|
+
? Reflect.get(timing, 'domContentLoadedEventStart')
|
|
140
|
+
: undefined;
|
|
141
|
+
return !(typeof started === 'number' && started > 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
function bootComing(): boolean {
|
|
90
145
|
if (typeof document === 'undefined' || typeof addEventListener !== 'function') return false;
|
|
91
|
-
if (
|
|
146
|
+
if (!deferredScriptsPending()) return false;
|
|
92
147
|
// A partial `document` (a component test's stand-in) has no query surface: no tag, no boot.
|
|
93
148
|
if (typeof document.querySelector !== 'function') return false;
|
|
94
149
|
return document.querySelector(`meta[name="${CLIENT_SCOPE_META}"]`) !== null;
|
|
@@ -96,25 +151,25 @@ function bootComing(): boolean {
|
|
|
96
151
|
|
|
97
152
|
type Host = { [KEY]?: PageRealtime };
|
|
98
153
|
|
|
99
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Get-or-create the page object — never its runtime: an island that asks before the runtime is
|
|
156
|
+
* installed (`installRealtime` seating a sync target) gets the object the runtime then fills.
|
|
157
|
+
*/
|
|
100
158
|
export function pageRealtime(): PageRealtime {
|
|
101
159
|
const host = globalThis as Host;
|
|
102
160
|
const existing = host[KEY];
|
|
103
161
|
if (existing !== undefined) return existing;
|
|
104
|
-
const store = new RecordStore();
|
|
105
162
|
const created: PageRealtime = {
|
|
106
|
-
store,
|
|
163
|
+
store: undefined,
|
|
107
164
|
sync: undefined,
|
|
108
165
|
socket: undefined,
|
|
109
166
|
writes: { pending: new Map(), failed: 0, listeners: new Set() },
|
|
110
167
|
get booted(): Promise<void> {
|
|
111
168
|
return bootedPromise();
|
|
112
169
|
},
|
|
170
|
+
services: undefined,
|
|
113
171
|
};
|
|
114
172
|
Object.defineProperty(host, KEY, { value: created, configurable: true });
|
|
115
|
-
pageClient().store = store;
|
|
116
|
-
// A new principal sees nothing of the previous one: every record goes, in memory, at once.
|
|
117
|
-
onRescope(() => store.clear());
|
|
118
173
|
return created;
|
|
119
174
|
}
|
|
120
175
|
|
|
@@ -123,6 +178,18 @@ export function peekPageRealtime(): PageRealtime | undefined {
|
|
|
123
178
|
return (globalThis as Host)[KEY];
|
|
124
179
|
}
|
|
125
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The page with its runtime installed, or `X_REALTIME_UNINSTALLED` naming `hook`. An island `x
|
|
183
|
+
* build` wrapped never sees the refusal — its bootstrap awaits the runtime before any hook runs.
|
|
184
|
+
*/
|
|
185
|
+
export function installedPage(hook: string): InstalledPage {
|
|
186
|
+
const page = pageRealtime();
|
|
187
|
+
if (page.store === undefined || page.services === undefined) {
|
|
188
|
+
throw new RealtimeUninstalledError({ hook });
|
|
189
|
+
}
|
|
190
|
+
return page as InstalledPage;
|
|
191
|
+
}
|
|
192
|
+
|
|
126
193
|
/**
|
|
127
194
|
* Whether this page holds a socket — `false` on a server render and before any live hook ran.
|
|
128
195
|
* The guard a component with a static fallback asks (an offline banner, an update prompt). Here,
|
|
@@ -132,7 +199,7 @@ export function hasPageSocket(): boolean {
|
|
|
132
199
|
return peekPageRealtime()?.socket !== undefined;
|
|
133
200
|
}
|
|
134
201
|
|
|
135
|
-
/** The page's record store. */
|
|
136
|
-
export function pageStore(): RecordStore {
|
|
137
|
-
return
|
|
202
|
+
/** The page's record store, from its installed runtime. */
|
|
203
|
+
export function pageStore(hook = 'pageStore'): RecordStore {
|
|
204
|
+
return installedPage(hook).store;
|
|
138
205
|
}
|
package/src/pg-advisory-lock.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { Rng } from './thundering-herd';
|
|
|
13
13
|
/** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
|
|
14
14
|
const KEY_PATTERN = /^[A-Za-z0-9:_.-]+$/;
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface PostgresAdvisoryLockOptions {
|
|
17
17
|
/** Connection string for the database whose replicator this is. */
|
|
18
18
|
readonly url: string;
|
|
19
19
|
/** Lock identity, e.g. `x:replicator:<slot>`. Hashed by Postgres, not by us. */
|
|
@@ -30,9 +30,9 @@ export interface PgAdvisoryLockOptions {
|
|
|
30
30
|
* already holds the key would need a matching second `pg_advisory_unlock`, and `release()` only
|
|
31
31
|
* ever issues one, so taking a second grant here would be a leak, not a no-op.
|
|
32
32
|
*/
|
|
33
|
-
export class
|
|
33
|
+
export class PostgresAdvisoryLock implements AdvisoryLock {
|
|
34
34
|
readonly key: string;
|
|
35
|
-
readonly #options:
|
|
35
|
+
readonly #options: PostgresAdvisoryLockOptions;
|
|
36
36
|
#connection: PgConnection | null = null;
|
|
37
37
|
/**
|
|
38
38
|
* The acquisition in flight, if any. `#connection` cannot answer "am I already taking this" —
|
|
@@ -47,7 +47,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
47
47
|
#acquiring: Promise<boolean> | null = null;
|
|
48
48
|
readonly #lostListeners = new Set<(reason: string) => void>();
|
|
49
49
|
|
|
50
|
-
constructor(options:
|
|
50
|
+
constructor(options: PostgresAdvisoryLockOptions) {
|
|
51
51
|
if (!KEY_PATTERN.test(options.key)) {
|
|
52
52
|
throw new ReplicationFailedError({
|
|
53
53
|
stage: 'preflight',
|
|
@@ -176,3 +176,8 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/** The one way to build the session lock — the twin of `memoryAdvisoryLock()`; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
181
|
+
export function postgresAdvisoryLock(options: PostgresAdvisoryLockOptions): PostgresAdvisoryLock {
|
|
182
|
+
return new PostgresAdvisoryLock(options);
|
|
183
|
+
}
|
package/src/pg-connection.ts
CHANGED
|
@@ -118,7 +118,13 @@ export class PgConnection {
|
|
|
118
118
|
// trips exactly on servers whose default is not already 3. A server that refuses one
|
|
119
119
|
// answers `ErrorResponse` during startup, so the replicator fails to boot with the server's
|
|
120
120
|
// own words — never a warning that a feed then goes on mis-sorting behind.
|
|
121
|
-
|
|
121
|
+
//
|
|
122
|
+
// `TimeZone=UTC` is the one pin libpqwalreceiver does not send, and it closes the same hole
|
|
123
|
+
// from the other side: a `timestamptz` is written in the SESSION's zone, so a server, database
|
|
124
|
+
// or role default like `America/New_York` writes a pre-1883 instant at its LMT offset,
|
|
125
|
+
// `-04:56:02`. The decoder reads that too, but UTC text is the one form with no ambient zone
|
|
126
|
+
// in it — and a client option outranks every server-side default, `ALTER ROLE … SET` included.
|
|
127
|
+
options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3 -c TimeZone=UTC',
|
|
122
128
|
};
|
|
123
129
|
if (options.replication !== undefined) parameters['replication'] = options.replication;
|
|
124
130
|
// A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
|
package/src/pg-replication.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the lsn is the only authority the pipeline has.
|
|
5
5
|
|
|
6
6
|
import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
|
-
import type { ChangeEvent, ChangeOp,
|
|
7
|
+
import type { ChangeEvent, ChangeOp, PostgresChangeFeedOptions } from './changefeed';
|
|
8
8
|
import { ReplicationProtocolError } from './errors';
|
|
9
9
|
import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
|
|
10
10
|
import { PgConnection } from './pg-connection';
|
|
@@ -90,11 +90,11 @@ export interface ReplicationStreamHandlers {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* One slot, one connection, one process. Started by `
|
|
93
|
+
* One slot, one connection, one process. Started by `PostgresChangeFeed`, which is itself
|
|
94
94
|
* held by the single `replicator` role — the advisory lock upstream is what keeps that "one" true.
|
|
95
95
|
*/
|
|
96
96
|
export class PgReplicationStream {
|
|
97
|
-
readonly #options:
|
|
97
|
+
readonly #options: PostgresChangeFeedOptions;
|
|
98
98
|
readonly #clock: Clock;
|
|
99
99
|
readonly #entities: ReadonlySet<string>;
|
|
100
100
|
readonly #slot: string;
|
|
@@ -116,7 +116,7 @@ export class PgReplicationStream {
|
|
|
116
116
|
#failure: string | null = null;
|
|
117
117
|
#onEnd: ((reason: string) => void) | undefined;
|
|
118
118
|
|
|
119
|
-
constructor(options:
|
|
119
|
+
constructor(options: PostgresChangeFeedOptions) {
|
|
120
120
|
this.#options = options;
|
|
121
121
|
this.#clock = options.clock ?? systemClock;
|
|
122
122
|
this.#entities = new Set(options.entities.map((name) => assertIdentifier('entity', name)));
|
|
@@ -414,7 +414,7 @@ export class PgReplicationStream {
|
|
|
414
414
|
}
|
|
415
415
|
const { before, after, omitted } = replicatedImages(relation, oldTuple, newTuple, unchanged);
|
|
416
416
|
const event: ChangeEvent = {
|
|
417
|
-
|
|
417
|
+
table: relation.name,
|
|
418
418
|
op,
|
|
419
419
|
before,
|
|
420
420
|
after,
|
|
@@ -422,7 +422,7 @@ export class PgReplicationStream {
|
|
|
422
422
|
txid: transaction.xid.toString(10),
|
|
423
423
|
orgId: tenantOf(after ?? before),
|
|
424
424
|
at: transaction.commitAt,
|
|
425
|
-
|
|
425
|
+
write: transaction.write ?? null,
|
|
426
426
|
...(omitted.length === 0 ? {} : { omitted }),
|
|
427
427
|
};
|
|
428
428
|
await handlers.onChange(event);
|
package/src/pg-socket.ts
CHANGED
|
@@ -52,6 +52,23 @@ export interface PgTarget {
|
|
|
52
52
|
readonly rootCert?: string | undefined;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One URL part, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray `%`
|
|
57
|
+
* (`50%off`), so a password pasted unencoded failed the replicator with no code and no fix.
|
|
58
|
+
* Refused under the part's NAME, never its value: that is the database credential.
|
|
59
|
+
*/
|
|
60
|
+
function decodeUrlPart(raw: string, part: 'user' | 'password' | 'database'): string {
|
|
61
|
+
try {
|
|
62
|
+
return decodeURIComponent(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new ReplicationFailedError({
|
|
65
|
+
stage: 'connect',
|
|
66
|
+
detail: `DATABASE_URL has a malformed percent-escape in its ${part}`,
|
|
67
|
+
fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of DATABASE_URL`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/** `postgres://user:pass@host:5432/db?sslmode=require`. The one place a connection URL is read. */
|
|
56
73
|
export function parsePgUrl(url: string): PgTarget {
|
|
57
74
|
let parsed: URL;
|
|
@@ -75,13 +92,13 @@ export function parsePgUrl(url: string): PgTarget {
|
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
94
|
const { ssl, rootCert } = parseSsl(parsed.searchParams);
|
|
78
|
-
const database =
|
|
95
|
+
const database = decodeUrlPart(parsed.pathname.replace(/^\//, ''), 'database');
|
|
79
96
|
return {
|
|
80
97
|
host: parsed.hostname,
|
|
81
98
|
port: parsed.port === '' ? 5432 : Number.parseInt(parsed.port, 10),
|
|
82
99
|
database: database === '' ? 'postgres' : database,
|
|
83
|
-
user:
|
|
84
|
-
password: parsed.password === '' ? undefined :
|
|
100
|
+
user: decodeUrlPart(parsed.username, 'user') || 'postgres',
|
|
101
|
+
password: parsed.password === '' ? undefined : decodeUrlPart(parsed.password, 'password'),
|
|
85
102
|
ssl,
|
|
86
103
|
rootCert,
|
|
87
104
|
};
|
package/src/pg-values.ts
CHANGED
|
@@ -33,13 +33,20 @@ export type PhysicalRow = { [key: string]: PhysicalValue };
|
|
|
33
33
|
/**
|
|
34
34
|
* `2026-08-09 12:00:00.123456+00` -> the ISO-8601 form `new Date` is specified to accept.
|
|
35
35
|
*
|
|
36
|
-
* Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530
|
|
37
|
-
* `+05:30
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530`,
|
|
37
|
+
* `+05:30` or — for an instant before its zone adopted standard time, a zone's LMT — `-04:56:02`,
|
|
38
|
+
* and as many fractional digits as the column's precision. `Date` holds milliseconds, so the
|
|
39
|
+
* fraction is TRUNCATED to three — which is what the driver does on the repository side, so both
|
|
40
|
+
* readers of one column land on the same instant.
|
|
41
|
+
*
|
|
42
|
+
* Seconds only ever follow a colon-separated minute (`EncodeTimezone` in postgres' `datetime.c`),
|
|
43
|
+
* so `-0456:02` is not a spelling this matches.
|
|
40
44
|
*/
|
|
41
45
|
const TIMESTAMP =
|
|
42
|
-
/^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(
|
|
46
|
+
/^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?:\d{2}|:\d{2}(?::\d{2})?)?)?$/;
|
|
47
|
+
|
|
48
|
+
/** `±HH`, `±HHMM`, `±HH:MM`, `±HH:MM:SS` — the zone group of `TIMESTAMP`, taken apart. */
|
|
49
|
+
const OFFSET = /^([+-])(\d{2}):?(\d{2})?:?(\d{2})?$/;
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
52
|
* `undefined` for a text this decoder does not describe — `infinity`, a BC date, a non-ISO
|
|
@@ -61,18 +68,26 @@ function toInstant(text: string): Date | undefined {
|
|
|
61
68
|
if (parts === null) return undefined;
|
|
62
69
|
const [, year, month, day, hour, minute, second, fraction, zone] = parts;
|
|
63
70
|
const millis = fraction === undefined ? '000' : `${fraction.slice(1)}000`.slice(0, 3);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
const offset = offsetMillis(zone);
|
|
72
|
+
if (offset === undefined) return undefined;
|
|
73
|
+
// Read the wall clock as UTC, then move it by the offset: `Date` has no spelling for a seconds
|
|
74
|
+
// offset, so handing it the zone would leave an LMT instant as text while the driver, which
|
|
75
|
+
// reads the binary form, hands the repository its `Date`.
|
|
76
|
+
const wall = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
|
|
77
|
+
if (Number.isNaN(wall)) return undefined;
|
|
78
|
+
const parsed = new Date(wall - offset);
|
|
67
79
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
if (zone === undefined || zone === 'Z') return
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
82
|
+
/** East of UTC is positive. `undefined` for a minute or second past 59 — not an offset at all. */
|
|
83
|
+
function offsetMillis(zone: string | undefined): number | undefined {
|
|
84
|
+
if (zone === undefined || zone === 'Z') return 0;
|
|
85
|
+
const parts = OFFSET.exec(zone);
|
|
86
|
+
if (parts === null) return undefined;
|
|
87
|
+
const [, sign, hours, minutes = '00', seconds = '00'] = parts;
|
|
88
|
+
if (Number(minutes) > 59 || Number(seconds) > 59) return undefined;
|
|
89
|
+
const magnitude = (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds)) * 1000;
|
|
90
|
+
return sign === '-' ? -magnitude : magnitude;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
const HEX = /^[0-9a-fA-F]*$/;
|
package/src/policy-gate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The single seam between realtime and authz. It goes through `@ultimat3/query`'s `
|
|
2
|
-
// is itself the only point of contact with `@ultimat3/policy` — one authz system, never two,
|
|
3
|
-
// realtime does not get its own opinion about what a decision means.
|
|
1
|
+
// The single seam between realtime and authz. It goes through `@ultimat3/query`'s `guardQuery`,
|
|
2
|
+
// which is itself the only point of contact with `@ultimat3/policy` — one authz system, never two,
|
|
3
|
+
// and realtime does not get its own opinion about what a decision means.
|
|
4
4
|
//
|
|
5
5
|
// The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
|
|
6
6
|
// policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// database is down" are different facts and one of them has to page someone.
|
|
9
9
|
|
|
10
10
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
|
-
import {
|
|
11
|
+
import { guardQuery, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
|
|
12
12
|
import type { JsonValue, Row } from './json';
|
|
13
13
|
|
|
14
14
|
export interface GateOptions {
|
|
@@ -25,7 +25,7 @@ export function authorizeWithPolicy(
|
|
|
25
25
|
return async (args) => {
|
|
26
26
|
// No row exists yet at subscribe time; `null` says so rather than leaving the predicate
|
|
27
27
|
// to infer it from an absent field.
|
|
28
|
-
|
|
28
|
+
guardQuery(policy, subjectOf(options, args.actor, args.input, null), 'live');
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -40,10 +40,10 @@ export function visibleWithPolicy<R extends Row = Row>(
|
|
|
40
40
|
): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
|
|
41
41
|
return async (args) => {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
guardQuery(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
|
|
44
44
|
return true;
|
|
45
45
|
} catch (error) {
|
|
46
|
-
// `
|
|
46
|
+
// `guardQuery` throws `QueryDeniedError` for a decision and for nothing else, so that class is
|
|
47
47
|
// the whole of "not visible". A rule that reached for a row and timed out throws something
|
|
48
48
|
// else, and answering `false` to it would report an outage as a permission change: the rows
|
|
49
49
|
// leave the subscriber's screen, `live.rows_denied` counts the drop, and no error reaches
|
package/src/presence.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// On the wire it is not a frame kind: a roster change is an `events` frame on the channel the member
|
|
7
7
|
// joined (`channel-presence.ts` is the payload), so only a channel declared `events: true` has one.
|
|
8
8
|
|
|
9
|
-
import { type Clock, finiteOption, systemClock,
|
|
9
|
+
import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core';
|
|
10
10
|
import type { ChannelHub, Topic } from './channel';
|
|
11
11
|
import { presenceEvent } from './channel-presence';
|
|
12
12
|
import type { ChannelEventsFrame } from './channel-wire';
|
|
@@ -86,7 +86,7 @@ export class PresenceRegistry {
|
|
|
86
86
|
1,
|
|
87
87
|
finiteOption('presence', 'maxMembers', options.maxMembers ?? DEFAULT_MAX_PRESENCE_MEMBERS),
|
|
88
88
|
);
|
|
89
|
-
this.#nodeId = options.nodeId ??
|
|
89
|
+
this.#nodeId = options.nodeId ?? uuidV7();
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
get ttlMs(): number {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// One principal's open sockets on this node, counted so the upgrade can refuse the next one. The
|
|
2
|
+
// per-actor SUBSCRIPTION cap bounds what a principal holds; this bounds how many sockets it holds it
|
|
3
|
+
// across — each one a grant, a frame budget, a heartbeat and a slot of `maxConnections`.
|
|
4
|
+
|
|
5
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sixteen sockets at the per-socket 128 subscriptions is 2,048 — past the per-actor 1,000, so a
|
|
9
|
+
* legitimate principal's tabs never hit this before they hit the subscription cap, while one
|
|
10
|
+
* principal can no longer take a node's `maxConnections` for itself.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_MAX_SOCKETS_PER_ACTOR = 16;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An ANONYMOUS principal is a network, not a person: an office behind one corporate NAT viewing a
|
|
16
|
+
* public live page is one `address:` key, and held to an actor's 16 it was 16 sockets for the whole
|
|
17
|
+
* floor. A network gets this many actors' worth — 128 at the default — while a signed-in actor keeps
|
|
18
|
+
* `maxSocketsPerActor`. The subscription cap is not multiplied.
|
|
19
|
+
*/
|
|
20
|
+
export const ANONYMOUS_SOCKET_MULTIPLIER = 8;
|
|
21
|
+
|
|
22
|
+
const isNetwork = (principal: string): boolean => principal.startsWith('address:');
|
|
23
|
+
|
|
24
|
+
/** A COUNT of at least 1, refused as the config key of the same name is. */
|
|
25
|
+
function socketCeiling(value: number | undefined): number {
|
|
26
|
+
const max = value ?? DEFAULT_MAX_SOCKETS_PER_ACTOR;
|
|
27
|
+
if (Number.isSafeInteger(max) && max >= 1) return max;
|
|
28
|
+
throw new ConfigInvalidError({
|
|
29
|
+
cause: `maxSocketsPerActor (realtime.maxSocketsPerActor) must be a whole number of at least 1, not ${String(max)} — 0 refuses every upgrade, and a fraction or a NaN is a cap no count ever reaches`,
|
|
30
|
+
fix: `syncNode({ maxSocketsPerActor: ${String(DEFAULT_MAX_SOCKETS_PER_ACTOR)} }) # or realtime: { maxSocketsPerActor: ${String(DEFAULT_MAX_SOCKETS_PER_ACTOR)} } in app.config.ts`,
|
|
31
|
+
meta: { key: 'realtime.maxSocketsPerActor', value: max },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Taken at the upgrade — synchronously, right before `server.upgrade`, so a herd parked in
|
|
37
|
+
* `authenticate` cannot all pass one count — and given back on EVERY path a socket ends: its
|
|
38
|
+
* close, and an upgrade that threw or declined after the slot was taken. `release` is idempotent.
|
|
39
|
+
*/
|
|
40
|
+
export class PrincipalSockets {
|
|
41
|
+
readonly max: number;
|
|
42
|
+
readonly #bySocket = new Map<string, string>();
|
|
43
|
+
readonly #count = new Map<string, number>();
|
|
44
|
+
|
|
45
|
+
constructor(max: number | undefined) {
|
|
46
|
+
this.max = socketCeiling(max);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Admit `socketId` under `principal`, or `false` when that principal is at its cap. */
|
|
50
|
+
admit(socketId: string, principal: string): boolean {
|
|
51
|
+
const held = this.#count.get(principal) ?? 0;
|
|
52
|
+
if (held >= this.limitFor(principal)) return false;
|
|
53
|
+
this.#bySocket.set(socketId, principal);
|
|
54
|
+
this.#count.set(principal, held + 1);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The cap `principal` is held to: `max` for an actor, `max × 8` for an anonymous network. */
|
|
59
|
+
limitFor(principal: string): number {
|
|
60
|
+
return isNetwork(principal) ? this.max * ANONYMOUS_SOCKET_MULTIPLIER : this.max;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
release(socketId: string): void {
|
|
64
|
+
const principal = this.#bySocket.get(socketId);
|
|
65
|
+
if (principal === undefined) return;
|
|
66
|
+
this.#bySocket.delete(socketId);
|
|
67
|
+
const next = (this.#count.get(principal) ?? 0) - 1;
|
|
68
|
+
if (next > 0) this.#count.set(principal, next);
|
|
69
|
+
else this.#count.delete(principal);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
count(principal: string): number {
|
|
73
|
+
return this.#count.get(principal) ?? 0;
|
|
74
|
+
}
|
|
75
|
+
}
|