@ultimat3/realtime 24.0.0 → 25.1.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/use-channel.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A declared channel, held by a component. Every holder of one topic on the page shares ONE
|
|
2
|
-
// membership on the page socket
|
|
3
|
-
// `useQuery`), and only `events`
|
|
2
|
+
// membership on the page socket — the page runtime's, reached off the page, never bundled here;
|
|
3
|
+
// its `records` land in the store (read them with `useRecord` / `useQuery`), and only `events`
|
|
4
|
+
// and presence reach the handlers given here.
|
|
4
5
|
|
|
5
6
|
import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
|
|
6
|
-
import {
|
|
7
|
+
import { installedPage } from './page-store';
|
|
7
8
|
import { isServerRender, signalFor } from './reactivity';
|
|
8
9
|
import type { PresenceMember } from './sync-protocol';
|
|
9
10
|
|
|
@@ -32,7 +33,9 @@ export function useChannel<K extends string>(
|
|
|
32
33
|
[Symbol.dispose]: nothing,
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
|
-
const membership =
|
|
36
|
+
const membership = installedPage('useChannel')
|
|
37
|
+
.services.socket('useChannel')
|
|
38
|
+
.holdChannel(declared, params, handlers);
|
|
36
39
|
const [version, setVersion] = signal(0);
|
|
37
40
|
const off = membership.onChange(() => setVersion(version() + 1));
|
|
38
41
|
const read = (): ChannelState => {
|
|
@@ -70,16 +73,18 @@ export function usePresence<K extends string>(
|
|
|
70
73
|
}
|
|
71
74
|
const [version, setVersion] = signal(0);
|
|
72
75
|
let members = new Map<string, PresenceMember>();
|
|
73
|
-
const membership =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (event.presence === '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
const membership = installedPage('usePresence')
|
|
77
|
+
.services.socket('usePresence')
|
|
78
|
+
.holdChannel(declared, params, {
|
|
79
|
+
onPresence: (event) => {
|
|
80
|
+
if (event.presence === 'sync') members = new Map();
|
|
81
|
+
for (const member of event.members) {
|
|
82
|
+
if (event.presence === 'leave') members.delete(member.id);
|
|
83
|
+
else members.set(member.id, member);
|
|
84
|
+
}
|
|
85
|
+
setVersion(version() + 1);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
83
88
|
const read = (): readonly PresenceMember[] => {
|
|
84
89
|
version();
|
|
85
90
|
return [...members.values()];
|
package/src/use-connection.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The page socket, as four getters: whether it is up, when it redials, and whether a newer build
|
|
2
2
|
// is live. Getters, never a snapshot, so a read inside a tracking scope stays live. Asking opens
|
|
3
|
-
// the socket — this hook IS a question about it.
|
|
3
|
+
// the socket — this hook IS a question about it — and the socket is the page runtime's.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { installedPage } from './page-store';
|
|
6
6
|
import { isServerRender, signalFor } from './reactivity';
|
|
7
7
|
|
|
8
8
|
export interface Connection extends Disposable {
|
|
@@ -33,7 +33,7 @@ const SERVER_RENDER: Connection = Object.freeze({
|
|
|
33
33
|
export function useConnection(): Connection {
|
|
34
34
|
const signal = signalFor('useConnection');
|
|
35
35
|
if (isServerRender()) return SERVER_RENDER;
|
|
36
|
-
const client =
|
|
36
|
+
const client = installedPage('useConnection').services.socket('useConnection');
|
|
37
37
|
const [version, setVersion] = signal(0);
|
|
38
38
|
const release = client.onStatus(() => setVersion(version() + 1));
|
|
39
39
|
return {
|
package/src/use-mutation.ts
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
// A client write, the one way: the mutator's optimistic twin into the store's OVERLAY (visible to
|
|
2
|
-
// every island before this returns), then its action over HTTP through core's one transport
|
|
3
|
-
// an idempotency key. The answer's records are adopted
|
|
4
|
-
// a refusal takes the overlay back.
|
|
2
|
+
// every island before this returns), then its action over HTTP through core's one transport — the
|
|
3
|
+
// page runtime's, called off the page — with an idempotency key. The answer's records are adopted
|
|
4
|
+
// before the overlay goes — no flicker — and a refusal takes the overlay back. No socket writes.
|
|
5
5
|
|
|
6
6
|
import type { ConflictPolicy } from '@ultimat3/core/page';
|
|
7
|
-
import {
|
|
8
|
-
actionPath,
|
|
9
|
-
clientTransport,
|
|
10
|
-
isSuperseded,
|
|
11
|
-
isUltimateError,
|
|
12
|
-
pageClient,
|
|
13
|
-
uuid,
|
|
14
|
-
} from '@ultimat3/core/page';
|
|
7
|
+
import { actionPath, isSuperseded, isUltimateError, pageClient, uuidV7 } from '@ultimat3/core/page';
|
|
15
8
|
import type { JsonValue } from './json';
|
|
16
9
|
import { type OutboxHandle, peekOutbox } from './outbox-slot';
|
|
17
10
|
import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
|
|
18
|
-
import { type PageWrites, pageRealtime } from './page-store';
|
|
11
|
+
import { installedPage, type PageWrites, pageRealtime } from './page-store';
|
|
19
12
|
import { isServerRender, signalFor } from './reactivity';
|
|
20
13
|
import { carriedBy } from './record-store';
|
|
21
14
|
|
|
@@ -93,7 +86,7 @@ function watch(hook: string, writes: PageWrites | undefined): [() => number, ()
|
|
|
93
86
|
function restoreOverlays(mutator: MutatorLike): void {
|
|
94
87
|
const local = mutator.local;
|
|
95
88
|
if (local === undefined) return;
|
|
96
|
-
const page =
|
|
89
|
+
const page = installedPage('useMutation');
|
|
97
90
|
// The boot opens the outbox; after it, so a queue the previous load left is on disk to read.
|
|
98
91
|
void page.booted.then(async () => {
|
|
99
92
|
const outbox = peekOutbox();
|
|
@@ -127,9 +120,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
127
120
|
const [version, release] = watch('useMutation', writes);
|
|
128
121
|
const call = async (input: JsonValue): Promise<unknown> => {
|
|
129
122
|
if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
|
|
130
|
-
const page =
|
|
123
|
+
const page = installedPage('useMutation');
|
|
131
124
|
const issued = pageClient().scope.epoch;
|
|
132
|
-
const key = `${mutator.name}:${
|
|
125
|
+
const key = `${mutator.name}:${uuidV7()}`;
|
|
133
126
|
const local = mutator.local;
|
|
134
127
|
// Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
|
|
135
128
|
if (local !== undefined) {
|
|
@@ -175,7 +168,7 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
175
168
|
/** The records the answer carried, `type:key` — what the overlay may be settled against. */
|
|
176
169
|
const carried = new Set<string>();
|
|
177
170
|
try {
|
|
178
|
-
output = await
|
|
171
|
+
output = await page.services.send({
|
|
179
172
|
method: 'POST',
|
|
180
173
|
url: actionPath(mutator.name),
|
|
181
174
|
body: input,
|
package/src/use-query.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
// The ONE read hook. Live-ness is the query's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The ONE read hook. Live-ness is the query's: a live ref subscribes over the page socket, any other
|
|
2
|
+
// is one HTTP read through `@ultimat3/query`'s client — both the page runtime's, never bundled here.
|
|
3
|
+
// Either way a list is an ORDER of keys and the rows are the page store's, so a record updated
|
|
4
|
+
// anywhere re-renders every list showing it without refetching the list.
|
|
5
5
|
|
|
6
6
|
import { type AsyncState, isSuperseded, type RecordEnvelope, type Row } from '@ultimat3/core/page';
|
|
7
|
-
import { queryClientMethodFor } from '@ultimat3/query/client';
|
|
8
7
|
import type { LiveHandle } from './client-contract';
|
|
9
8
|
import type { JsonValue } from './json';
|
|
10
|
-
import {
|
|
11
|
-
import { pageStore } from './page-store';
|
|
9
|
+
import { installedPage, type PageServices } from './page-store';
|
|
12
10
|
import { isServerRender, signalFor } from './reactivity';
|
|
13
11
|
import { type RecordStore, recordKey } from './record-store';
|
|
14
12
|
|
|
@@ -71,11 +69,12 @@ export function useQuery<R extends object = Row>(
|
|
|
71
69
|
[Symbol.dispose]: nothing,
|
|
72
70
|
});
|
|
73
71
|
}
|
|
72
|
+
const page = installedPage('useQuery');
|
|
74
73
|
const [version, setVersion] = signal(0);
|
|
75
74
|
const bump = (): void => setVersion(version() + 1);
|
|
76
75
|
return ref.live === true
|
|
77
|
-
? liveAccessor<R>(
|
|
78
|
-
: readAccessor<R>(
|
|
76
|
+
? liveAccessor<R>(page.services.socket('useQuery').subscribeLive<R>(ref, input), version, bump)
|
|
77
|
+
: readAccessor<R>(page.store, page.services.read(ref.name), ref, input, options, version, bump);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
function liveAccessor<R extends object>(
|
|
@@ -115,6 +114,7 @@ function liveAccessor<R extends object>(
|
|
|
115
114
|
|
|
116
115
|
function readAccessor<R extends object>(
|
|
117
116
|
store: RecordStore,
|
|
117
|
+
method: ReturnType<PageServices['read']>,
|
|
118
118
|
ref: QueryRef,
|
|
119
119
|
input: JsonValue,
|
|
120
120
|
options: QueryOptions,
|
|
@@ -122,7 +122,6 @@ function readAccessor<R extends object>(
|
|
|
122
122
|
bump: () => void,
|
|
123
123
|
): QueryAccessor<R> {
|
|
124
124
|
const type = ref.entity;
|
|
125
|
-
const method = queryClientMethodFor(ref.name, { baseUrl: '' });
|
|
126
125
|
let state: AsyncState<readonly Row[]> = PENDING;
|
|
127
126
|
/** Record keys in answer order when the rows are records; the rows themselves when not. */
|
|
128
127
|
let keys: readonly string[] = [];
|
|
@@ -177,9 +176,10 @@ function readAccessor<R extends object>(
|
|
|
177
176
|
append && after !== null ? { first: options.first, after } : { first: options.first };
|
|
178
177
|
// The page's cursor travels WITH its rows and is taken only where the rows are: a `more()`
|
|
179
178
|
// a refetch superseded wrote its cursor here, before the generation check discarded its rows.
|
|
179
|
+
// `nextCursor` is `null` exactly when `hasMore` is false (core's `Page`), so it IS the answer.
|
|
180
180
|
return method.page(input, controls, { onEnvelope }).then((page) => ({
|
|
181
181
|
...answered(page.rows as readonly Row[]),
|
|
182
|
-
next: page.
|
|
182
|
+
next: page.nextCursor,
|
|
183
183
|
}));
|
|
184
184
|
};
|
|
185
185
|
|
package/src/use-record.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// One record, by type and key, out of the page's store — the same object every island showing it
|
|
2
2
|
// holds, moved once by whichever write lands first (an HTTP answer, a socket frame, an optimistic
|
|
3
|
-
// twin).
|
|
3
|
+
// twin). A reader of the page runtime's store: an island that only reads records ships the hook and
|
|
4
|
+
// none of the store, the socket or its lifecycle.
|
|
4
5
|
|
|
5
6
|
import type { AsyncState, Row } from '@ultimat3/core/page';
|
|
6
7
|
import { pageStore } from './page-store';
|
|
7
8
|
import { isServerRender, signalFor } from './reactivity';
|
|
8
|
-
import { recordKey } from './record-store';
|
|
9
|
+
import { type RecordStore, recordKey } from './record-store';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A callable `AsyncState`: `pending` until the record first arrives, `ready` from then on —
|
|
@@ -31,7 +32,7 @@ export function useRecord<R extends object = Row>(type: string, key: string): Re
|
|
|
31
32
|
[Symbol.dispose]: releaseNothing,
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
|
-
const store = pageStore();
|
|
35
|
+
const store = pageStore('useRecord');
|
|
35
36
|
const [version, setVersion] = signal(0);
|
|
36
37
|
const target = recordKey(type, key);
|
|
37
38
|
store.retain(type, key);
|
|
@@ -59,20 +60,37 @@ export function useRecord<R extends object = Row>(type: string, key: string): Re
|
|
|
59
60
|
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/** A callable `AsyncState` over several records
|
|
63
|
+
/** A callable `AsyncState` over several records of one type. */
|
|
63
64
|
export type RecordsAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
|
|
64
65
|
release(): void;
|
|
65
66
|
[Symbol.dispose](): void;
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
70
|
+
* Which records of a type a whole-type `useRecords` shows, and in what order. Both run on every
|
|
71
|
+
* change to that type, in the browser, over rows already on the page: pure, and cheap.
|
|
72
|
+
*/
|
|
73
|
+
export interface RecordSelection<R extends object = Row> {
|
|
74
|
+
/** Keep a record only when this answers `true`. Absent: every record of the type. */
|
|
75
|
+
readonly where?: (record: R) => boolean;
|
|
76
|
+
/** Absent: by record key, ascending — creation order for a uuid v7 key. */
|
|
77
|
+
readonly order?: (a: R, b: R) => number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Records of one type out of the page store — the same objects `useRecord` hands out.
|
|
82
|
+
*
|
|
83
|
+
* - **By key** (`useRecords('posts', keys)`): `pending` until the first of them arrives, then
|
|
84
|
+
* `ready` with those present, in key order; a record the server removed drops out.
|
|
85
|
+
* - **The whole type** (`useRecords('runs', {})`, `useRecords('runs', { where, order })`): every record
|
|
86
|
+
* of it the store holds — HTTP answers, live patches, a channel's `records` frames and pending
|
|
87
|
+
* optimistic writes alike — `ready` from the first read (an empty store is an empty list; whether
|
|
88
|
+
* the page has caught up is the channel's or the query's to say). A record a frame adopts joins
|
|
89
|
+
* the list; none is evicted while it is held. The channel-records counterpart of a live window.
|
|
72
90
|
*/
|
|
73
91
|
export function useRecords<R extends object = Row>(
|
|
74
92
|
type: string,
|
|
75
|
-
|
|
93
|
+
select: readonly string[] | RecordSelection<R>,
|
|
76
94
|
): RecordsAccessor<R> {
|
|
77
95
|
const signal = signalFor('useRecords');
|
|
78
96
|
if (isServerRender()) {
|
|
@@ -81,8 +99,24 @@ export function useRecords<R extends object = Row>(
|
|
|
81
99
|
[Symbol.dispose]: releaseNothing,
|
|
82
100
|
});
|
|
83
101
|
}
|
|
84
|
-
const store = pageStore();
|
|
102
|
+
const store = pageStore('useRecords');
|
|
85
103
|
const [version, setVersion] = signal(0);
|
|
104
|
+
const bump = (): void => setVersion(version() + 1);
|
|
105
|
+
return isKeyList(select)
|
|
106
|
+
? byKeys<R>(store, type, select, version, bump)
|
|
107
|
+
: ofType<R>(store, type, select, version, bump);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `Array.isArray` narrows a `readonly` array union to `any[]`; this keeps the element type.
|
|
111
|
+
const isKeyList = (value: unknown): value is readonly string[] => Array.isArray(value);
|
|
112
|
+
|
|
113
|
+
function byKeys<R extends object>(
|
|
114
|
+
store: RecordStore,
|
|
115
|
+
type: string,
|
|
116
|
+
keys: readonly string[],
|
|
117
|
+
version: () => number,
|
|
118
|
+
bump: () => void,
|
|
119
|
+
): RecordsAccessor<R> {
|
|
86
120
|
const targets = new Set(keys.map((key) => recordKey(type, key)));
|
|
87
121
|
const present = (): R[] => {
|
|
88
122
|
const out: R[] = [];
|
|
@@ -100,7 +134,7 @@ export function useRecords<R extends object = Row>(
|
|
|
100
134
|
const unsubscribe = store.subscribe((changed) => {
|
|
101
135
|
if (![...changed].some((key) => targets.has(key))) return;
|
|
102
136
|
if (present().length > 0) seen = true;
|
|
103
|
-
|
|
137
|
+
bump();
|
|
104
138
|
});
|
|
105
139
|
const read = (): AsyncState<readonly R[]> => {
|
|
106
140
|
version();
|
|
@@ -119,3 +153,48 @@ export function useRecords<R extends object = Row>(
|
|
|
119
153
|
};
|
|
120
154
|
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
121
155
|
}
|
|
156
|
+
|
|
157
|
+
function ofType<R extends object>(
|
|
158
|
+
store: RecordStore,
|
|
159
|
+
type: string,
|
|
160
|
+
selection: RecordSelection<R>,
|
|
161
|
+
version: () => number,
|
|
162
|
+
bump: () => void,
|
|
163
|
+
): RecordsAccessor<R> {
|
|
164
|
+
const prefix = `${type}:`;
|
|
165
|
+
const { where, order } = selection;
|
|
166
|
+
store.retainType(type);
|
|
167
|
+
// Recomputed once per change to this type, never per read: the same answer back is what tells
|
|
168
|
+
// a fine-grained renderer nothing moved.
|
|
169
|
+
let answer: AsyncState<readonly R[]> | null = null;
|
|
170
|
+
let answered = -1;
|
|
171
|
+
const unsubscribe = store.subscribe((changed) => {
|
|
172
|
+
for (const rk of changed) {
|
|
173
|
+
if (!rk.startsWith(prefix)) continue;
|
|
174
|
+
bump();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const read = (): AsyncState<readonly R[]> => {
|
|
179
|
+
const at = version();
|
|
180
|
+
if (answer !== null && answered === at) return answer;
|
|
181
|
+
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
182
|
+
const keyed = store.keyed(type) as readonly (readonly [string, R])[];
|
|
183
|
+
const kept = where === undefined ? [...keyed] : keyed.filter(([, row]) => where(row));
|
|
184
|
+
const rows =
|
|
185
|
+
order === undefined
|
|
186
|
+
? kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, row]) => row)
|
|
187
|
+
: kept.map(([, row]) => row).sort(order);
|
|
188
|
+
answer = { status: 'ready', data: rows };
|
|
189
|
+
answered = at;
|
|
190
|
+
return answer;
|
|
191
|
+
};
|
|
192
|
+
let held = true;
|
|
193
|
+
const release = (): void => {
|
|
194
|
+
if (!held) return;
|
|
195
|
+
held = false;
|
|
196
|
+
unsubscribe();
|
|
197
|
+
store.releaseType(type);
|
|
198
|
+
};
|
|
199
|
+
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
200
|
+
}
|
package/src/wire-channel.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
ChannelSubscribeTarget,
|
|
13
13
|
ReplayGapFrame,
|
|
14
14
|
} from './channel-wire';
|
|
15
|
-
import {
|
|
15
|
+
import { isParsedJsonObject, type JsonObject } from './json';
|
|
16
16
|
import { fail, list, num, object, str } from './wire-read';
|
|
17
17
|
import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
18
18
|
|
|
@@ -77,7 +77,7 @@ export function channelTarget(value: JsonObject): ChannelSubscribeTarget {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function sinceOf(value: unknown): ChannelSince {
|
|
80
|
-
if (!
|
|
80
|
+
if (!isParsedJsonObject(value)) throw fail('channel since must be an object');
|
|
81
81
|
const seq = num(value, 'seq');
|
|
82
82
|
if (!Number.isInteger(seq) || seq < 0) throw fail('channel since.seq must be a whole number');
|
|
83
83
|
return { epoch: str(value, 'epoch'), seq };
|
|
@@ -85,11 +85,11 @@ function sinceOf(value: unknown): ChannelSince {
|
|
|
85
85
|
|
|
86
86
|
/** type → key → row. Every row an object, and the whole frame under the snapshot row ceiling. */
|
|
87
87
|
function adoptOf(value: unknown): ChannelAdopt {
|
|
88
|
-
if (!
|
|
88
|
+
if (!isParsedJsonObject(value)) throw fail('records.adopt must be an object');
|
|
89
89
|
let total = 0;
|
|
90
90
|
const out = bare<Readonly<Record<string, Row>>>();
|
|
91
91
|
for (const [type, keyed] of Object.entries(value)) {
|
|
92
|
-
if (!
|
|
92
|
+
if (!isParsedJsonObject(keyed)) throw fail(`records.adopt.${type} must be an object`);
|
|
93
93
|
const rows = bare<Row>();
|
|
94
94
|
for (const [key, row] of Object.entries(keyed)) {
|
|
95
95
|
total += 1;
|
|
@@ -104,7 +104,7 @@ function adoptOf(value: unknown): ChannelAdopt {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function removeOf(value: unknown): ChannelRemove {
|
|
107
|
-
if (!
|
|
107
|
+
if (!isParsedJsonObject(value)) throw fail('records.remove must be an object');
|
|
108
108
|
const out = bare<readonly string[]>();
|
|
109
109
|
for (const type of Object.keys(value)) {
|
|
110
110
|
out[type] = list(value, type, FRAME_LIMITS.rows, `records.remove.${type}`).map((key) => {
|
package/src/wire-read.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// refusal is `X_PROTOCOL_VERSION` with the field named. Shared by `sync-protocol.ts` and
|
|
3
3
|
// `wire-channel.ts`, so the channel frames are held to the same ceilings as every other kind.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { isParsedJsonObject, type JsonObject, type JsonValue } from './json';
|
|
6
6
|
import { ProtocolVersionError } from './page-errors';
|
|
7
7
|
import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
8
8
|
|
|
@@ -81,6 +81,6 @@ export function bounded(value: JsonValue, label: string): JsonValue {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function object(value: unknown): JsonObject {
|
|
84
|
-
if (!
|
|
84
|
+
if (!isParsedJsonObject(value)) throw fail('expected a JSON object');
|
|
85
85
|
return value;
|
|
86
86
|
}
|
package/src/idb-fake.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* An in-memory IndexedDB with exactly the surface `local-store-idb.ts` uses — open with an
|
|
3
|
-
* upgrade, one transaction over named stores, `put` / `delete` / `getAll` / `getAllKeys` — and
|
|
4
|
-
* IndexedDB's own asynchrony: every request answers on a later microtask and a transaction
|
|
5
|
-
* completes after its last request. Bun ships no IndexedDB, and no dependency is worth a test seam.
|
|
6
|
-
* Values are structured-cloned in and out, as the real one does.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike, IdbStoreLike } from './idb-types';
|
|
10
|
-
|
|
11
|
-
type Tables = Map<string, Map<string, unknown>>;
|
|
12
|
-
|
|
13
|
-
export interface FakeIdbOptions {
|
|
14
|
-
/** `open` fails, as it does in a private window or with storage blocked. */
|
|
15
|
-
readonly blocked?: boolean;
|
|
16
|
-
/**
|
|
17
|
-
* Every write ABORTS its transaction, as a quota refusal does: `abort` fires and nothing else —
|
|
18
|
-
* no `complete`, no transaction `error` event. Read at write time, so a test can flip it.
|
|
19
|
-
*/
|
|
20
|
-
quotaExceeded?: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** A fresh, empty database server. Share one instance to simulate a reload over the same disk. */
|
|
24
|
-
export function fakeIndexedDb(options: FakeIdbOptions = {}): IdbFactoryLike {
|
|
25
|
-
const databases = new Map<string, { version: number; tables: Tables }>();
|
|
26
|
-
return {
|
|
27
|
-
open(name: string, version: number): IdbRequestLike<IdbDatabaseLike> {
|
|
28
|
-
const request = pendingRequest<IdbDatabaseLike>();
|
|
29
|
-
queueMicrotask(() => {
|
|
30
|
-
if (options.blocked === true) {
|
|
31
|
-
request.fail(new DOMException('The operation is insecure.', 'SecurityError'));
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const known = databases.get(name) ?? { version: 0, tables: new Map() };
|
|
35
|
-
databases.set(name, known);
|
|
36
|
-
const db = database(known.tables, options);
|
|
37
|
-
request.result = db;
|
|
38
|
-
if (known.version < version) {
|
|
39
|
-
known.version = version;
|
|
40
|
-
request.onupgradeneeded?.();
|
|
41
|
-
}
|
|
42
|
-
request.onsuccess?.();
|
|
43
|
-
});
|
|
44
|
-
return request;
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function database(tables: Tables, options: FakeIdbOptions): IdbDatabaseLike {
|
|
50
|
-
return {
|
|
51
|
-
objectStoreNames: { contains: (name: string): boolean => tables.has(name) },
|
|
52
|
-
createObjectStore: (name: string): void => {
|
|
53
|
-
tables.set(name, new Map());
|
|
54
|
-
},
|
|
55
|
-
transaction(names: string | readonly string[]) {
|
|
56
|
-
let open = 0;
|
|
57
|
-
let failed = false;
|
|
58
|
-
const tx = {
|
|
59
|
-
oncomplete: null as (() => void) | null,
|
|
60
|
-
onerror: null as (() => void) | null,
|
|
61
|
-
onabort: null as (() => void) | null,
|
|
62
|
-
error: null as unknown,
|
|
63
|
-
objectStore: (name: string): IdbStoreLike => store(name),
|
|
64
|
-
};
|
|
65
|
-
const abort = (): void => {
|
|
66
|
-
if (failed) return;
|
|
67
|
-
failed = true;
|
|
68
|
-
tx.error = new DOMException('The quota has been exceeded.', 'QuotaExceededError');
|
|
69
|
-
queueMicrotask(() => tx.onabort?.());
|
|
70
|
-
};
|
|
71
|
-
const settleLater = (): void => {
|
|
72
|
-
queueMicrotask(() => {
|
|
73
|
-
if (open > 0 || failed) return;
|
|
74
|
-
tx.oncomplete?.();
|
|
75
|
-
});
|
|
76
|
-
};
|
|
77
|
-
const run = <T>(work: () => T): IdbRequestLike<T> => {
|
|
78
|
-
const request = pendingRequest<T>();
|
|
79
|
-
open += 1;
|
|
80
|
-
queueMicrotask(() => {
|
|
81
|
-
open -= 1;
|
|
82
|
-
request.result = work();
|
|
83
|
-
request.onsuccess?.();
|
|
84
|
-
settleLater();
|
|
85
|
-
});
|
|
86
|
-
return request;
|
|
87
|
-
};
|
|
88
|
-
const store = (name: string): IdbStoreLike => {
|
|
89
|
-
const allowed = typeof names === 'string' ? [names] : names;
|
|
90
|
-
const table = tables.get(name);
|
|
91
|
-
if (!allowed.includes(name) || table === undefined) {
|
|
92
|
-
throw new DOMException(`no object store ${name}`, 'NotFoundError');
|
|
93
|
-
}
|
|
94
|
-
const sorted = (): [string, unknown][] =>
|
|
95
|
-
[...table].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
96
|
-
return {
|
|
97
|
-
get: (key) => run(() => structuredClone(table.get(key))),
|
|
98
|
-
put: (value, key) =>
|
|
99
|
-
run(() => {
|
|
100
|
-
if (options.quotaExceeded === true) {
|
|
101
|
-
abort();
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
table.set(key, structuredClone(value));
|
|
105
|
-
}),
|
|
106
|
-
delete: (key) => run(() => void table.delete(key)),
|
|
107
|
-
getAll: () => run(() => sorted().map(([, value]) => structuredClone(value))),
|
|
108
|
-
getAllKeys: () => run(() => sorted().map(([key]) => key)),
|
|
109
|
-
};
|
|
110
|
-
};
|
|
111
|
-
settleLater();
|
|
112
|
-
return tx;
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
type PendingRequest<T> = IdbRequestLike<T> & { fail(error: unknown): void };
|
|
118
|
-
|
|
119
|
-
function pendingRequest<T>(): PendingRequest<T> {
|
|
120
|
-
const request: PendingRequest<T> = {
|
|
121
|
-
result: undefined as T,
|
|
122
|
-
error: null,
|
|
123
|
-
onsuccess: null,
|
|
124
|
-
onerror: null,
|
|
125
|
-
onupgradeneeded: null,
|
|
126
|
-
onblocked: null,
|
|
127
|
-
fail(error: unknown): void {
|
|
128
|
-
request.error = error;
|
|
129
|
-
request.onerror?.();
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
return request;
|
|
133
|
-
}
|
package/src/policy-fake.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// A policy that admits every caller, for the tests that declare a channel whose authz is not
|
|
2
|
-
// their subject. Structural, never built with `@ultimat3/policy`: realtime reaches that package
|
|
3
|
-
// only through `@ultimat3/query`'s `guard`, and a direct import would be a second authz path.
|
|
4
|
-
// An app says the same thing with `allow('public')`, which is what this stands in for.
|
|
5
|
-
|
|
6
|
-
import type { QueryPolicy } from '@ultimat3/query';
|
|
7
|
-
|
|
8
|
-
export const OPEN_POLICY: QueryPolicy = Object.freeze({
|
|
9
|
-
kind: 'allow',
|
|
10
|
-
label: 'public',
|
|
11
|
-
permissions: [],
|
|
12
|
-
children: [],
|
|
13
|
-
run: () => ({ allowed: true as const }),
|
|
14
|
-
});
|