@ultimat3/realtime 1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +36 -0
- package/src/change-buffer.ts +69 -0
- package/src/changefeed-env.ts +146 -0
- package/src/changefeed.ts +191 -0
- package/src/channel.ts +181 -0
- package/src/client.ts +439 -0
- package/src/cursor.ts +188 -0
- package/src/errors.ts +253 -0
- package/src/fanout.ts +159 -0
- package/src/hooks.ts +230 -0
- package/src/index.ts +328 -0
- package/src/json.ts +76 -0
- package/src/live-definition.ts +144 -0
- package/src/live-query.ts +449 -0
- package/src/local-store.ts +188 -0
- package/src/matcher-bridge.ts +169 -0
- package/src/nats-commands.ts +97 -0
- package/src/nats-connection-fixture.ts +105 -0
- package/src/nats-connection.ts +464 -0
- package/src/nats-fake.ts +431 -0
- package/src/nats-jetstream.ts +226 -0
- package/src/nats-kv.ts +157 -0
- package/src/nats-protocol.ts +222 -0
- package/src/nats-socket.ts +236 -0
- package/src/nats-transport.ts +257 -0
- package/src/offline-queue.ts +206 -0
- package/src/pg-advisory-lock.ts +98 -0
- package/src/pg-auth.ts +300 -0
- package/src/pg-bytes.ts +185 -0
- package/src/pg-connection-fixture.ts +215 -0
- package/src/pg-connection.ts +337 -0
- package/src/pg-entity-row.ts +130 -0
- package/src/pg-replication-fixture.ts +261 -0
- package/src/pg-replication.ts +396 -0
- package/src/pg-socket.ts +265 -0
- package/src/pg-wire.ts +192 -0
- package/src/pgoutput.ts +297 -0
- package/src/policy-gate.ts +56 -0
- package/src/presence.ts +219 -0
- package/src/rebase.ts +198 -0
- package/src/replicator.ts +185 -0
- package/src/socket.ts +208 -0
- package/src/sync-node.ts +400 -0
- package/src/sync-protocol.ts +376 -0
- package/src/thundering-herd.ts +141 -0
- package/src/transport-env.ts +104 -0
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The four calls a component makes: one live query, one connection view, one mutator call, one
|
|
2
|
+
// queue view. Nothing here is a reactive runtime — realtime never imports solid-js, so every
|
|
3
|
+
// accessor is a closure over the `SignalFactory` the registered `LiveClient` was built with, and
|
|
4
|
+
// every hook resolves that client through one ambient seam rather than a context per surface.
|
|
5
|
+
|
|
6
|
+
import type { LiveClient, LiveHandle, LiveQueryRef, MutatorRef } from './client';
|
|
7
|
+
import { LiveClientMissingError } from './errors';
|
|
8
|
+
import type { JsonValue, Row } from './json';
|
|
9
|
+
import type { LocalTx } from './local-store';
|
|
10
|
+
import type { ConflictStrategy } from './rebase';
|
|
11
|
+
|
|
12
|
+
/** What `setLiveClient` holds. The version signal is the queue's only reactive handle — see below. */
|
|
13
|
+
interface Registered {
|
|
14
|
+
readonly client: LiveClient;
|
|
15
|
+
/** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
|
|
16
|
+
readonly version: () => number;
|
|
17
|
+
readonly bump: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let registered: Registered | null = null;
|
|
21
|
+
|
|
22
|
+
/** Register once, in the app entry, before the first render. One app, one socket, one client. */
|
|
23
|
+
export function setLiveClient(client: LiveClient): void {
|
|
24
|
+
const [version, setVersion] = client.signal<number>(0);
|
|
25
|
+
const bump = (): void => {
|
|
26
|
+
setVersion(version() + 1);
|
|
27
|
+
};
|
|
28
|
+
registered = { client, version, bump };
|
|
29
|
+
// Closes the gap a direct call can't: a reconnect drains automatically inside `connect()`, and
|
|
30
|
+
// an ack/fail frame arrives asynchronously inside `#onFrame` — neither is awaited by any hook, so
|
|
31
|
+
// this is the only path that reaches them. The direct `bump()` calls below stay too: they fire at
|
|
32
|
+
// the earliest possible moment for the call that made them, and a redundant bump is harmless.
|
|
33
|
+
client.onQueueChange(bump);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** For tests: drop the registration so cases stay independent. */
|
|
37
|
+
export function clearLiveClient(): void {
|
|
38
|
+
registered = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hasLiveClient(): boolean {
|
|
42
|
+
return registered !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function live(hook: string): Registered {
|
|
46
|
+
if (registered === null) throw new LiveClientMissingError({ hook });
|
|
47
|
+
return registered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- useLive ------------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** The query's input, or a thunk returning it. The thunk is read once — see `useLive`. */
|
|
53
|
+
export type LiveInput = JsonValue | (() => JsonValue);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A callable result set: `feed()` are the rows, `feed.state()` / `feed.cursor()` /
|
|
57
|
+
* `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it.
|
|
58
|
+
*/
|
|
59
|
+
export type LiveRows<R extends Row = Row> = (() => readonly R[]) & Omit<LiveHandle<R>, 'rows'>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Subscribe to a live query. `query` is anything carrying a `name`, which a `@ultimat3/query`
|
|
63
|
+
* `Query` satisfies structurally — realtime is tier 3, so it names the shape rather than importing
|
|
64
|
+
* the package sideways.
|
|
65
|
+
*
|
|
66
|
+
* A thunk `input` is read **once**, at subscribe time: tier 3 has no reactive runtime of its own,
|
|
67
|
+
* so nothing re-runs it when its dependencies change. Changing input means a new subscription.
|
|
68
|
+
* The caller owns `unsubscribe` — nothing here disposes on unmount, because nothing here knows
|
|
69
|
+
* what a mount is.
|
|
70
|
+
*/
|
|
71
|
+
export function useLive<R extends Row = Row>(query: LiveQueryRef, input: LiveInput): LiveRows<R> {
|
|
72
|
+
const handle = live('useLive').client.useLive<R>(
|
|
73
|
+
query,
|
|
74
|
+
typeof input === 'function' ? input() : input,
|
|
75
|
+
);
|
|
76
|
+
return Object.assign((): readonly R[] => handle.rows(), {
|
|
77
|
+
state: handle.state,
|
|
78
|
+
cursor: handle.cursor,
|
|
79
|
+
unsubscribe: handle.unsubscribe,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- useConnection ------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface Connection {
|
|
86
|
+
readonly offline: boolean;
|
|
87
|
+
readonly online: boolean;
|
|
88
|
+
/** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
|
|
89
|
+
readonly reconnectAt: number | null;
|
|
90
|
+
/** The buildId the server announced, or `null` while this build is current. */
|
|
91
|
+
readonly updateAvailable: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The socket, as four booleans and two values. Every member is a **getter**, so a read inside a
|
|
96
|
+
* tracking scope reaches the underlying signal; a snapshot object would freeze the answer at the
|
|
97
|
+
* moment the component rendered and never say "offline" again.
|
|
98
|
+
*/
|
|
99
|
+
export function useConnection(): Connection {
|
|
100
|
+
const client = live('useConnection').client;
|
|
101
|
+
return {
|
|
102
|
+
get offline() {
|
|
103
|
+
return !client.connected;
|
|
104
|
+
},
|
|
105
|
+
get online() {
|
|
106
|
+
return client.connected;
|
|
107
|
+
},
|
|
108
|
+
get reconnectAt() {
|
|
109
|
+
return client.reconnectAt();
|
|
110
|
+
},
|
|
111
|
+
get updateAvailable() {
|
|
112
|
+
return client.appUpdateAvailable();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- useMutation --------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Both spellings of a conflict strategy: realtime's `custom()` (`{ kind: 'custom' }`, merging rows)
|
|
121
|
+
* and `@ultimat3/action`'s (`{ strategy: 'custom' }`, merging parsed outputs).
|
|
122
|
+
*/
|
|
123
|
+
export type ConflictLike = ConflictStrategy | { readonly strategy: 'custom' };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a hook needs from a mutator: the name it queues under, and the optimistic twin. A
|
|
127
|
+
* `@ultimat3/action` `Mutator` satisfies it structurally.
|
|
128
|
+
*
|
|
129
|
+
* `local` is declared with **method syntax** on purpose. TypeScript relates method parameters
|
|
130
|
+
* bivariantly, so a `Mutator` whose `local` takes its own parsed input and its own `tx` assigns
|
|
131
|
+
* here with no cast at the call site; written as a function-typed property, `strictFunctionTypes`
|
|
132
|
+
* would reject exactly that mutator. The parameters are `unknown` because this layer never reads
|
|
133
|
+
* them — it binds them and hands the closure to the client.
|
|
134
|
+
*/
|
|
135
|
+
export interface MutatorLike {
|
|
136
|
+
readonly name: string;
|
|
137
|
+
local?(tx: unknown, input: unknown): void;
|
|
138
|
+
readonly entity?: string;
|
|
139
|
+
readonly conflict?: ConflictLike;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A callable mutator with its own queue depth attached. */
|
|
143
|
+
export type Mutate = ((input: JsonValue) => Promise<void>) & {
|
|
144
|
+
/** Queued mutations of this mutator. Always `0` at tier 2, where nothing is queued. */
|
|
145
|
+
readonly pending: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Call a mutator: optimistic twin, rebase entry, durable queue, drain — all of it inside
|
|
150
|
+
* `LiveClient.mutate`. `pending` is a getter, so it stays reactive for as long as the component
|
|
151
|
+
* reads it; it refreshes on every call routed through this hook and on `useMutationQueue().drain`.
|
|
152
|
+
*/
|
|
153
|
+
export function useMutation(mutator: MutatorLike): Mutate {
|
|
154
|
+
const state = live('useMutation');
|
|
155
|
+
const ref = mutatorRef(mutator);
|
|
156
|
+
const call = async (input: JsonValue): Promise<void> => {
|
|
157
|
+
await state.client.mutate(ref, input);
|
|
158
|
+
state.bump();
|
|
159
|
+
};
|
|
160
|
+
Object.defineProperty(call, 'pending', {
|
|
161
|
+
get: (): number => {
|
|
162
|
+
state.version();
|
|
163
|
+
const queue = state.client.queue;
|
|
164
|
+
return queue === undefined
|
|
165
|
+
? 0
|
|
166
|
+
: queue.pending().filter((mutation) => mutation.name === mutator.name).length;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// `defineProperty` cannot widen a function type, so the assembled shape is asserted once, here.
|
|
170
|
+
return call as Mutate;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- useMutationQueue ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export interface MutationQueue {
|
|
176
|
+
/** Queued and not yet acknowledged, across every mutator. */
|
|
177
|
+
readonly pending: number;
|
|
178
|
+
/** Terminally failed — a policy denial or a validation error, kept for the UI, never retried. */
|
|
179
|
+
readonly failed: number;
|
|
180
|
+
drain(): Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The whole durable queue, as two counts and the one command that empties it. */
|
|
184
|
+
export function useMutationQueue(): MutationQueue {
|
|
185
|
+
const state = live('useMutationQueue');
|
|
186
|
+
return {
|
|
187
|
+
get pending() {
|
|
188
|
+
state.version();
|
|
189
|
+
return state.client.queue?.pending().length ?? 0;
|
|
190
|
+
},
|
|
191
|
+
get failed() {
|
|
192
|
+
state.version();
|
|
193
|
+
const all = state.client.queue?.all() ?? [];
|
|
194
|
+
return all.filter((mutation) => mutation.status === 'failed').length;
|
|
195
|
+
},
|
|
196
|
+
drain: async () => {
|
|
197
|
+
await state.client.drain();
|
|
198
|
+
state.bump();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The hook's mutator, as the client's. Only the twin needs binding; the rest is carried through. */
|
|
204
|
+
function mutatorRef(mutator: MutatorLike): MutatorRef {
|
|
205
|
+
const conflict = replayable(mutator.conflict);
|
|
206
|
+
return {
|
|
207
|
+
name: mutator.name,
|
|
208
|
+
// Called back through the mutator rather than through a hoisted reference: `local` may be
|
|
209
|
+
// written as a method, and an unbound method loses the receiver its body could read.
|
|
210
|
+
...(mutator.local === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: {
|
|
213
|
+
local: (tx: LocalTx, input: JsonValue) => {
|
|
214
|
+
mutator.local?.(tx, input);
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
...(mutator.entity === undefined ? {} : { entity: mutator.entity }),
|
|
218
|
+
...(conflict === undefined ? {} : { conflict }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `@ultimat3/action`'s `custom(merge)` merges parsed *outputs*; realtime's merges *rows*. Only the
|
|
224
|
+
* second is a `CustomMerge` `reconcile` can replay, so the other spelling is dropped rather than
|
|
225
|
+
* handed to a rebase that would call it with the wrong argument — the log then takes its default.
|
|
226
|
+
*/
|
|
227
|
+
function replayable(conflict: ConflictLike | undefined): ConflictStrategy | undefined {
|
|
228
|
+
if (conflict === undefined || typeof conflict === 'string') return conflict;
|
|
229
|
+
return 'kind' in conflict ? conflict : undefined;
|
|
230
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Public API. Explicit, tier by tier: channels, live queries, local-first sync, plus the wire and
|
|
2
|
+
// the server/client halves that carry all three.
|
|
3
|
+
|
|
4
|
+
export { type ChangeBufferOptions, RingChangeBuffer } from './change-buffer';
|
|
5
|
+
// ---- tier 2: live queries ----------------------------------------------------------------------
|
|
6
|
+
export {
|
|
7
|
+
type ChangeEvent,
|
|
8
|
+
type ChangeFeed,
|
|
9
|
+
type ChangeFeedStartOptions,
|
|
10
|
+
type ChangeOp,
|
|
11
|
+
formatLsn,
|
|
12
|
+
InMemoryChangeFeed,
|
|
13
|
+
type InMemoryChangeFeedOptions,
|
|
14
|
+
PgLogicalReplicationFeed,
|
|
15
|
+
type PgLogicalReplicationOptions,
|
|
16
|
+
parseLsn,
|
|
17
|
+
} from './changefeed';
|
|
18
|
+
export {
|
|
19
|
+
type ChangeFeedSelection,
|
|
20
|
+
DEFAULT_REPLICATION_PUBLICATION,
|
|
21
|
+
DEFAULT_REPLICATION_SLOT,
|
|
22
|
+
REPLICATION_ENV_KEYS,
|
|
23
|
+
type ReplicationEnvironment,
|
|
24
|
+
replicatorLockKey,
|
|
25
|
+
type SelectChangeFeedOptions,
|
|
26
|
+
selectChangeFeed,
|
|
27
|
+
} from './changefeed-env';
|
|
28
|
+
|
|
29
|
+
// ---- tier 1: channels + presence ---------------------------------------------------------------
|
|
30
|
+
export {
|
|
31
|
+
ChannelHub,
|
|
32
|
+
type ChannelHubOptions,
|
|
33
|
+
channelFrame,
|
|
34
|
+
type Topic,
|
|
35
|
+
type TopicGuard,
|
|
36
|
+
type TopicGuardArgs,
|
|
37
|
+
type TopicGuardResult,
|
|
38
|
+
topic,
|
|
39
|
+
} from './channel';
|
|
40
|
+
// ---- server + client halves -------------------------------------------------------------------
|
|
41
|
+
export {
|
|
42
|
+
applyPatches,
|
|
43
|
+
type ClientSocket,
|
|
44
|
+
LiveClient,
|
|
45
|
+
type LiveClientOptions,
|
|
46
|
+
type LiveHandle,
|
|
47
|
+
type LiveQueryRef,
|
|
48
|
+
type LiveState,
|
|
49
|
+
type MutatorRef,
|
|
50
|
+
type SignalFactory,
|
|
51
|
+
} from './client';
|
|
52
|
+
// ---- reconnect ----------------------------------------------------------------------------------
|
|
53
|
+
export {
|
|
54
|
+
advance,
|
|
55
|
+
CURSOR_ID_LIMIT,
|
|
56
|
+
DIGEST_UNVERIFIED,
|
|
57
|
+
defaultReconnectBudget,
|
|
58
|
+
digestOf,
|
|
59
|
+
type LiveCursor,
|
|
60
|
+
makeCursor,
|
|
61
|
+
type ReconnectBudget,
|
|
62
|
+
type ResumeDecision,
|
|
63
|
+
type ResumeDeps,
|
|
64
|
+
type ResumeReason,
|
|
65
|
+
type ResumeResult,
|
|
66
|
+
type ResumeSource,
|
|
67
|
+
resumeFrom,
|
|
68
|
+
shouldResnapshot,
|
|
69
|
+
verifyDigest,
|
|
70
|
+
} from './cursor';
|
|
71
|
+
// ---- errors ----------------------------------------------------------------------------------
|
|
72
|
+
export {
|
|
73
|
+
CursorStaleError,
|
|
74
|
+
LiveClientMissingError,
|
|
75
|
+
LiveRowUnidentifiedError,
|
|
76
|
+
NotImplementedError,
|
|
77
|
+
ProtocolVersionError,
|
|
78
|
+
REALTIME_ERROR_CODES,
|
|
79
|
+
REALTIME_ERROR_TITLES,
|
|
80
|
+
RealtimeError,
|
|
81
|
+
type RealtimeErrorCode,
|
|
82
|
+
RebaseConflictError,
|
|
83
|
+
ReplicationFailedError,
|
|
84
|
+
ReplicationProtocolError,
|
|
85
|
+
ReplicatorSlotHeldError,
|
|
86
|
+
SubscriptionLimitError,
|
|
87
|
+
TopicForbiddenError,
|
|
88
|
+
TransportProtocolError,
|
|
89
|
+
TransportUnavailableError,
|
|
90
|
+
} from './errors';
|
|
91
|
+
export {
|
|
92
|
+
InProcessTransport,
|
|
93
|
+
type InProcessTransportOptions,
|
|
94
|
+
subjectMatches,
|
|
95
|
+
type Transport,
|
|
96
|
+
type TransportHandler,
|
|
97
|
+
type TransportSet,
|
|
98
|
+
type TransportSetEntry,
|
|
99
|
+
type TransportSubscription,
|
|
100
|
+
} from './fanout';
|
|
101
|
+
// ---- the client hooks --------------------------------------------------------------------------
|
|
102
|
+
export {
|
|
103
|
+
type ConflictLike,
|
|
104
|
+
type Connection,
|
|
105
|
+
clearLiveClient,
|
|
106
|
+
hasLiveClient,
|
|
107
|
+
type LiveInput,
|
|
108
|
+
type LiveRows,
|
|
109
|
+
type Mutate,
|
|
110
|
+
type MutationQueue,
|
|
111
|
+
type MutatorLike,
|
|
112
|
+
setLiveClient,
|
|
113
|
+
useConnection,
|
|
114
|
+
useLive,
|
|
115
|
+
useMutation,
|
|
116
|
+
useMutationQueue,
|
|
117
|
+
} from './hooks';
|
|
118
|
+
// ---- shared value domain ---------------------------------------------------------------------
|
|
119
|
+
export {
|
|
120
|
+
canonicalJson,
|
|
121
|
+
changedColumns,
|
|
122
|
+
fnv1a,
|
|
123
|
+
isJsonObject,
|
|
124
|
+
isRow,
|
|
125
|
+
type JsonObject,
|
|
126
|
+
type JsonValue,
|
|
127
|
+
type Row,
|
|
128
|
+
type RowOp,
|
|
129
|
+
type RowPatch,
|
|
130
|
+
} from './json';
|
|
131
|
+
export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';
|
|
132
|
+
export {
|
|
133
|
+
type LiveQueryDefinition,
|
|
134
|
+
LiveQueryRegistry,
|
|
135
|
+
type LiveQueryRegistryOptions,
|
|
136
|
+
type LiveSubscription,
|
|
137
|
+
qidOf,
|
|
138
|
+
type RowDenied,
|
|
139
|
+
type SnapshotResult,
|
|
140
|
+
} from './live-query';
|
|
141
|
+
// ---- tier 3: local-first ------------------------------------------------------------------------
|
|
142
|
+
export {
|
|
143
|
+
createOpfsLocalStore,
|
|
144
|
+
type LocalStore,
|
|
145
|
+
type LocalTable,
|
|
146
|
+
type LocalTx,
|
|
147
|
+
MemoryLocalStore,
|
|
148
|
+
type OpfsLocalStoreOptions,
|
|
149
|
+
type TableMap,
|
|
150
|
+
} from './local-store';
|
|
151
|
+
export {
|
|
152
|
+
applyToWindow,
|
|
153
|
+
type BridgeResult,
|
|
154
|
+
bridgeChange,
|
|
155
|
+
canAffect,
|
|
156
|
+
type IncrementalMatcher,
|
|
157
|
+
matcherFor,
|
|
158
|
+
NO_CHANGE,
|
|
159
|
+
normalizePatch,
|
|
160
|
+
patchFromChange,
|
|
161
|
+
type SubscriptionShape,
|
|
162
|
+
toBridgeResult,
|
|
163
|
+
} from './matcher-bridge';
|
|
164
|
+
export type { NatsConnectOptions } from './nats-commands';
|
|
165
|
+
// ---- the production bus -------------------------------------------------------------------------
|
|
166
|
+
export {
|
|
167
|
+
NatsConnection,
|
|
168
|
+
type NatsConnectionOptions,
|
|
169
|
+
type NatsMessageHandler,
|
|
170
|
+
type NatsSubscription,
|
|
171
|
+
} from './nats-connection';
|
|
172
|
+
export { type FakeNatsOptions, FakeNatsServer, fakeNatsStream } from './nats-fake';
|
|
173
|
+
export {
|
|
174
|
+
assertBucket,
|
|
175
|
+
assertServerVersion,
|
|
176
|
+
ensureKvBucket,
|
|
177
|
+
type JsError,
|
|
178
|
+
type KvRecord,
|
|
179
|
+
kvGet,
|
|
180
|
+
kvLast,
|
|
181
|
+
kvSubject,
|
|
182
|
+
kvWrite,
|
|
183
|
+
} from './nats-jetstream';
|
|
184
|
+
export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
|
|
185
|
+
export {
|
|
186
|
+
type NatsHeaders,
|
|
187
|
+
type NatsMessage,
|
|
188
|
+
type NatsOperation,
|
|
189
|
+
NatsProtocolParser,
|
|
190
|
+
type NatsServerInfo,
|
|
191
|
+
} from './nats-protocol';
|
|
192
|
+
export {
|
|
193
|
+
bunNatsStream,
|
|
194
|
+
type NatsStream,
|
|
195
|
+
type NatsTarget,
|
|
196
|
+
natsStreamOver,
|
|
197
|
+
parseNatsUrl,
|
|
198
|
+
} from './nats-socket';
|
|
199
|
+
export { NatsTransport, type NatsTransportOptions } from './nats-transport';
|
|
200
|
+
export {
|
|
201
|
+
type DrainReport,
|
|
202
|
+
MemoryQueueStore,
|
|
203
|
+
type MutationSender,
|
|
204
|
+
type MutationStatus,
|
|
205
|
+
mutateFrame,
|
|
206
|
+
OfflineQueue,
|
|
207
|
+
type QueuedMutation,
|
|
208
|
+
type QueueState,
|
|
209
|
+
type QueueStore,
|
|
210
|
+
} from './offline-queue';
|
|
211
|
+
export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
|
|
212
|
+
// ---- the postgres replication path ------------------------------------------------------------
|
|
213
|
+
export { camel, entityRow } from './pg-entity-row';
|
|
214
|
+
export {
|
|
215
|
+
changeLsn,
|
|
216
|
+
commitPositionOf,
|
|
217
|
+
type ReplicationStreamStats,
|
|
218
|
+
} from './pg-replication';
|
|
219
|
+
export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
|
|
220
|
+
export type { PgStream } from './pg-wire';
|
|
221
|
+
export {
|
|
222
|
+
type PgColumn,
|
|
223
|
+
PgOutputDecoder,
|
|
224
|
+
type PgOutputMessage,
|
|
225
|
+
type PgRelation,
|
|
226
|
+
} from './pgoutput';
|
|
227
|
+
export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
|
|
228
|
+
export {
|
|
229
|
+
PRESENCE_KEY_PREFIX,
|
|
230
|
+
type PresenceInput,
|
|
231
|
+
type PresenceOptions,
|
|
232
|
+
PresenceRegistry,
|
|
233
|
+
presenceFrame,
|
|
234
|
+
} from './presence';
|
|
235
|
+
export {
|
|
236
|
+
type ConflictStrategy,
|
|
237
|
+
type CustomMerge,
|
|
238
|
+
custom,
|
|
239
|
+
type MergeArgs,
|
|
240
|
+
type RebaseEntry,
|
|
241
|
+
RebaseLog,
|
|
242
|
+
type ReconcileOptions,
|
|
243
|
+
type ReconcileResult,
|
|
244
|
+
rebaseFrame,
|
|
245
|
+
reconcile,
|
|
246
|
+
type ServerAck,
|
|
247
|
+
strategyName,
|
|
248
|
+
} from './rebase';
|
|
249
|
+
export {
|
|
250
|
+
type AdvisoryLock,
|
|
251
|
+
CHANGE_SUBJECT_PREFIX,
|
|
252
|
+
changeSubject,
|
|
253
|
+
createReplicator,
|
|
254
|
+
InMemoryAdvisoryLock,
|
|
255
|
+
normalize,
|
|
256
|
+
parseChange,
|
|
257
|
+
type Replicator,
|
|
258
|
+
type ReplicatorOptions,
|
|
259
|
+
type ReplicatorStats,
|
|
260
|
+
} from './replicator';
|
|
261
|
+
export {
|
|
262
|
+
actorIdOf,
|
|
263
|
+
CLOSE,
|
|
264
|
+
SocketRegistry,
|
|
265
|
+
type SocketRegistryOptions,
|
|
266
|
+
SyncSocket,
|
|
267
|
+
type SyncSocketOptions,
|
|
268
|
+
type WsLike,
|
|
269
|
+
} from './socket';
|
|
270
|
+
export {
|
|
271
|
+
createSyncNode,
|
|
272
|
+
type ListenOptions,
|
|
273
|
+
listenSyncNode,
|
|
274
|
+
type MutationHandler,
|
|
275
|
+
type SyncListener,
|
|
276
|
+
type SyncNode,
|
|
277
|
+
type SyncNodeOptions,
|
|
278
|
+
type SyncWs,
|
|
279
|
+
type UpgradeTarget,
|
|
280
|
+
type WsData,
|
|
281
|
+
} from './sync-node';
|
|
282
|
+
// ---- the wire -------------------------------------------------------------------------------------
|
|
283
|
+
export {
|
|
284
|
+
type AckFrame,
|
|
285
|
+
type ConflictStrategyName,
|
|
286
|
+
decode,
|
|
287
|
+
encode,
|
|
288
|
+
FRAME_KINDS,
|
|
289
|
+
type Frame,
|
|
290
|
+
type FrameKind,
|
|
291
|
+
type HelloFrame,
|
|
292
|
+
type MutateFrame,
|
|
293
|
+
type PatchFrame,
|
|
294
|
+
PROTOCOL_VERSION,
|
|
295
|
+
type PresenceFrame,
|
|
296
|
+
type PresenceMember,
|
|
297
|
+
type RebaseFrame,
|
|
298
|
+
type ReconnectFrame,
|
|
299
|
+
type SnapshotFrame,
|
|
300
|
+
type SubscribeFrame,
|
|
301
|
+
type SubscribeTarget,
|
|
302
|
+
toWireError,
|
|
303
|
+
type UpdateAvailableFrame,
|
|
304
|
+
type WireError,
|
|
305
|
+
} from './sync-protocol';
|
|
306
|
+
export {
|
|
307
|
+
AcceptBudget,
|
|
308
|
+
type AcceptBudgetOptions,
|
|
309
|
+
type BackoffPolicy,
|
|
310
|
+
backoffDelay,
|
|
311
|
+
type DrainPlanEntry,
|
|
312
|
+
type DrainPlanOptions,
|
|
313
|
+
defaultBackoff,
|
|
314
|
+
drainPlan,
|
|
315
|
+
type JitterMode,
|
|
316
|
+
type ReconnectReason,
|
|
317
|
+
type Rng,
|
|
318
|
+
reconnectFrame,
|
|
319
|
+
} from './thundering-herd';
|
|
320
|
+
export {
|
|
321
|
+
DEFAULT_PRESENCE_BUCKET,
|
|
322
|
+
DEFAULT_PRESENCE_TTL_MS,
|
|
323
|
+
type SelectTransportOptions,
|
|
324
|
+
selectTransport,
|
|
325
|
+
TRANSPORT_ENV_KEYS,
|
|
326
|
+
type TransportEnvironment,
|
|
327
|
+
type TransportSelection,
|
|
328
|
+
} from './transport-env';
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The JSON value domain shared by the wire, the matcher, and the local store.
|
|
2
|
+
// Kept dependency-free so every other module in this package can import it without a cycle.
|
|
3
|
+
|
|
4
|
+
export type JsonValue =
|
|
5
|
+
| string
|
|
6
|
+
| number
|
|
7
|
+
| boolean
|
|
8
|
+
| null
|
|
9
|
+
| JsonValue[]
|
|
10
|
+
| { [key: string]: JsonValue };
|
|
11
|
+
|
|
12
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
13
|
+
|
|
14
|
+
/** Every row that crosses the wire or lands in the local store is identified by `id`. */
|
|
15
|
+
export type Row = JsonObject & { id: string };
|
|
16
|
+
|
|
17
|
+
export type RowOp = 'insert' | 'update' | 'delete';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The minimal delta for one row in one subscription's result set.
|
|
21
|
+
* `index` is present only for ordered result sets, so a client can splice instead of re-sort.
|
|
22
|
+
*/
|
|
23
|
+
export interface RowPatch {
|
|
24
|
+
readonly op: RowOp;
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** `null` for deletes. For updates this is the changed columns only, never the whole row. */
|
|
27
|
+
readonly row: JsonObject | null;
|
|
28
|
+
readonly lsn: string;
|
|
29
|
+
readonly index?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isRow(value: unknown): value is Row {
|
|
37
|
+
return isJsonObject(value) && typeof value['id'] === 'string';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shallow diff used to keep update patches minimal — the pipeline never ships unchanged columns. */
|
|
41
|
+
export function changedColumns(before: JsonObject | null, after: JsonObject): JsonObject {
|
|
42
|
+
if (before === null) return after;
|
|
43
|
+
const out: JsonObject = {};
|
|
44
|
+
for (const key of Object.keys(after)) {
|
|
45
|
+
const next = after[key];
|
|
46
|
+
if (next === undefined) continue;
|
|
47
|
+
if (!sameJson(before[key], next)) out[key] = next;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Key-sorted JSON so a query id derived from input is stable across property order. */
|
|
53
|
+
export function canonicalJson(value: JsonValue): string {
|
|
54
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
55
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
56
|
+
const keys = Object.keys(value).sort();
|
|
57
|
+
const parts = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key] ?? null)}`);
|
|
58
|
+
return `{${parts.join(',')}}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** FNV-1a, 32-bit, hex. Not cryptographic — it identifies and detects drift, it does not protect. */
|
|
62
|
+
export function fnv1a(text: string): string {
|
|
63
|
+
let hash = 0x811c9dc5;
|
|
64
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
65
|
+
hash ^= text.charCodeAt(i);
|
|
66
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
return hash.toString(16).padStart(8, '0');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sameJson(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
|
|
72
|
+
if (a === b) return true;
|
|
73
|
+
if (a === undefined || b === undefined || a === null || b === null) return false;
|
|
74
|
+
if (typeof a !== 'object' || typeof b !== 'object') return false;
|
|
75
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
76
|
+
}
|