@ultimat3/realtime 23.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- 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 +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- 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 +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- 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 +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- 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-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- 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 +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
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
|
|
|
@@ -65,7 +65,7 @@ export function channelTarget(value: JsonObject): ChannelSubscribeTarget {
|
|
|
65
65
|
`channel params carry ${entries.length}, over the limit of ${FRAME_LIMITS.channelParams}`,
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
|
-
const params
|
|
68
|
+
const params = bare<string>();
|
|
69
69
|
for (const [key, param] of entries) {
|
|
70
70
|
if (typeof param !== 'string') throw fail(`channel param "${key}" must be a string`);
|
|
71
71
|
params[key] = param;
|
|
@@ -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,12 +85,12 @@ 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
|
-
const out
|
|
90
|
+
const out = bare<Readonly<Record<string, Row>>>();
|
|
91
91
|
for (const [type, keyed] of Object.entries(value)) {
|
|
92
|
-
if (!
|
|
93
|
-
const rows
|
|
92
|
+
if (!isParsedJsonObject(keyed)) throw fail(`records.adopt.${type} must be an object`);
|
|
93
|
+
const rows = bare<Row>();
|
|
94
94
|
for (const [key, row] of Object.entries(keyed)) {
|
|
95
95
|
total += 1;
|
|
96
96
|
if (total > FRAME_LIMITS.rows) {
|
|
@@ -104,8 +104,8 @@ function adoptOf(value: unknown): ChannelAdopt {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function removeOf(value: unknown): ChannelRemove {
|
|
107
|
-
if (!
|
|
108
|
-
const out
|
|
107
|
+
if (!isParsedJsonObject(value)) throw fail('records.remove must be an object');
|
|
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) => {
|
|
111
111
|
if (typeof key !== 'string') throw fail(`records.remove.${type} must hold strings`);
|
|
@@ -114,3 +114,12 @@ function removeOf(value: unknown): ChannelRemove {
|
|
|
114
114
|
}
|
|
115
115
|
return out;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A map keyed by WIRE data — a record type, a record key, a channel param. Null-prototype, as the
|
|
120
|
+
* encoder's are (`channel-render.ts`): on a plain object `out['__proto__'] = rows` sets the map's
|
|
121
|
+
* prototype instead of a key, so that entry was dropped and every other read went through it.
|
|
122
|
+
*/
|
|
123
|
+
function bare<V>(): Record<string, V> {
|
|
124
|
+
return Object.create(null) as Record<string, V>;
|
|
125
|
+
}
|
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
|
-
});
|