@spooky-sync/core 0.0.1-canary.355 → 0.0.1-canary.356
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/AGENTS.md +1 -0
- package/dist/index.d.ts +104 -5
- package/dist/index.js +1651 -19
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +238 -72
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +4 -0
- package/src/client/router.test.ts +1 -0
- package/src/client/router.ts +3 -1
- package/src/client/sp00ky-client.test.ts +87 -0
- package/src/client/sp00ky-client.ts +121 -9
- package/src/index.ts +17 -0
- package/src/kernel/events.ts +1 -0
- package/src/modules/channel/channel.test.ts +752 -0
- package/src/modules/channel/index.ts +1153 -0
- package/src/modules/channel/webrtc.ts +472 -0
- package/src/mutation/rows.ts +16 -0
- package/src/mutation/write.saga.test.ts +122 -2
- package/src/mutation/write.saga.ts +117 -1
- package/src/query/materialize.saga.test.ts +12 -0
- package/src/query/materialize.saga.ts +7 -1
- package/src/state/client-state.ts +15 -0
- package/src/state/reducers.test.ts +18 -0
- package/src/state/reducers.ts +21 -0
- package/src/state/selectors.test.ts +15 -0
- package/src/state/selectors.ts +7 -2
- package/src/types.ts +39 -1
package/dist/otel/index.d.ts
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,218 @@
|
|
|
1
|
-
import { AccessDefinition, ColumnSchema, QueryPlan, QueryPlan as QueryPlan$1, RecordId, SchemaStructure, TypeNameToTypeMap, WhereNode } from "@spooky-sync/query-builder";
|
|
1
|
+
import { AccessDefinition, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryPlan, QueryPlan as QueryPlan$1, RecordId, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap, WhereNode, WhereNode as WhereNode$1 } from "@spooky-sync/query-builder";
|
|
2
2
|
import { RecordId as RecordId$1, Surreal, SurrealEvents, SurrealTransaction } from "surrealdb";
|
|
3
3
|
import { Level, Level as Level$1, Logger, LoggerOptions } from "pino";
|
|
4
4
|
|
|
5
|
-
//#region src/
|
|
5
|
+
//#region src/services/logger/index.d.ts
|
|
6
|
+
type Logger$1 = Logger;
|
|
7
|
+
/** One line the logger wrote, as the DevTools Logs tab and the MCP read it. */
|
|
8
|
+
interface CapturedLog {
|
|
9
|
+
/** Monotonic for the page's lifetime; a reader resumes after the last one it holds. */
|
|
10
|
+
seq: number;
|
|
11
|
+
time: number;
|
|
12
|
+
/** Pino's numeric level: 10 trace … 60 fatal. */
|
|
13
|
+
level: number;
|
|
14
|
+
msg: string;
|
|
15
|
+
/** The whole log object as JSON (what the console printed), capped at {@link LogTap.LINE_MAX}. */
|
|
16
|
+
line: string;
|
|
17
|
+
}
|
|
18
|
+
interface LogRead {
|
|
19
|
+
entries: CapturedLog[];
|
|
20
|
+
/** `seq` of the newest captured line; pass it back as `after` to resume. */
|
|
21
|
+
head: number;
|
|
22
|
+
/** Lines after `after` the ring overwrote before they were read (cleared ones excluded). */
|
|
23
|
+
dropped: number;
|
|
24
|
+
consoleLevel: Level;
|
|
25
|
+
captureLevel: Level;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* JSON for a log object that can never throw into the call site: Errors keep
|
|
29
|
+
* their message and stack (plain `JSON.stringify` prints `{}`), bigints become
|
|
30
|
+
* strings, and a cycle prints `[Circular]` instead of throwing.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The page-side log buffer behind the DevTools Logs tab. Always on and
|
|
35
|
+
* bounded: a line costs the JSON the console write produces anyway, and a
|
|
36
|
+
* panel opened after boot still sees the boot.
|
|
37
|
+
*
|
|
38
|
+
* It also owns the logger's level. The console prints at the configured level
|
|
39
|
+
* only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
|
|
40
|
+
* console), live on every child logger, and keeps it for this tab's session so
|
|
41
|
+
* a reload captures its boot at that level too.
|
|
42
|
+
*/
|
|
43
|
+
declare class LogTap {
|
|
44
|
+
readonly consoleLevel: Level;
|
|
45
|
+
static readonly CAPACITY = 500;
|
|
46
|
+
static readonly LINE_MAX = 4000;
|
|
47
|
+
private readonly ring;
|
|
48
|
+
private seq;
|
|
49
|
+
/** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
|
|
50
|
+
private clearedThrough;
|
|
51
|
+
private readonly listeners;
|
|
52
|
+
private readonly loggers;
|
|
53
|
+
private captureLevelValue;
|
|
54
|
+
constructor(consoleLevel: Level);
|
|
55
|
+
/** `seq` of the newest captured line. */
|
|
56
|
+
get head(): number;
|
|
57
|
+
get captureLevel(): Level;
|
|
58
|
+
/** What pino has to produce for both the console and the buffer. */
|
|
59
|
+
get effectiveLevel(): Level;
|
|
60
|
+
/** Whether the console prints a line at this numeric level. */
|
|
61
|
+
printsToConsole(level: number): boolean;
|
|
62
|
+
/** Remember a logger (the root and every child) so a level change reaches it. */
|
|
63
|
+
track(logger: Logger$1): void;
|
|
64
|
+
setCaptureLevel(level: Level): void;
|
|
65
|
+
record(o: Record<string, unknown>, line: string): void;
|
|
66
|
+
/** Lines after `after` (0 = everything still buffered), oldest first. */
|
|
67
|
+
read(after?: number, limit?: number): LogRead;
|
|
68
|
+
clear(): void;
|
|
69
|
+
/** Called synchronously inside the log call: keep it cheap, never log from it. */
|
|
70
|
+
subscribe(cb: () => void): () => void;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/modules/channel/index.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* Peer to peer channels (`-- @channel` tables, `client.channel(query, peers)`).
|
|
76
|
+
*
|
|
77
|
+
* A channel is a filter (the `where` of one or more queries on `@channel`
|
|
78
|
+
* tables) plus the users it connects to. Writes to rows that match the
|
|
79
|
+
* filter reach the users' tabs straight over the transport (WebRTC by
|
|
80
|
+
* default), and their tabs fold them in before the database has seen them.
|
|
81
|
+
* The filter is evaluated here, in plain JS, against the row; the query is
|
|
82
|
+
* never registered. Optimistic writes, distributed:
|
|
83
|
+
*
|
|
84
|
+
* - Updates: persistent fields still go through the normal outbox. A peer
|
|
85
|
+
* shows the sent value until its own synced row catches up to it, or for
|
|
86
|
+
* `confirmTimeoutMs` at most, so a value the server rejected (or that a
|
|
87
|
+
* peer made up) falls back to the server's on its own.
|
|
88
|
+
* - `-- @ephemeral` fields never reach the database. They live in memory,
|
|
89
|
+
* last writer wins, and a peer's values vanish when it disconnects.
|
|
90
|
+
* - Creates go into the peer's store and circuit like its own optimistic
|
|
91
|
+
* create (minus the outbox); deletes hide the row. Both are undone unless
|
|
92
|
+
* the server confirms them (see the peer sagas in `mutation/write.saga.ts`).
|
|
93
|
+
*
|
|
94
|
+
* Nothing here enforces permissions beyond identity: the transport proves
|
|
95
|
+
* which user a peer is (the database stamps the signaling rows), a message
|
|
96
|
+
* is only taken from a user listed by an open channel whose filter the row
|
|
97
|
+
* matches, and fields must exist and fit their column type. Everything else
|
|
98
|
+
* is the app's call, through `channels.accept`.
|
|
99
|
+
*
|
|
100
|
+
* Update and ephemeral values are folded into subscription output only,
|
|
101
|
+
* never into the local store.
|
|
102
|
+
*/
|
|
103
|
+
type Row$1 = Record<string, unknown>;
|
|
104
|
+
/** Hybrid logical clock: wall ms, counter, tab peer id. Orders every write. */
|
|
105
|
+
type Hlc = readonly [ms: number, counter: number, peer: string];
|
|
106
|
+
/** One tab of one user. */
|
|
107
|
+
interface PeerRef {
|
|
108
|
+
readonly user: string;
|
|
109
|
+
readonly peer: string;
|
|
110
|
+
}
|
|
111
|
+
/** What a message does to its row. */
|
|
112
|
+
type ChannelOpKind = 'update' | 'create' | 'delete';
|
|
113
|
+
/** The one message channels exchange: a write to one row. */
|
|
114
|
+
interface ChannelMessage {
|
|
115
|
+
readonly t: 'op';
|
|
116
|
+
/** Absent means `update` (a field patch). */
|
|
117
|
+
readonly kind?: ChannelOpKind;
|
|
118
|
+
readonly table: string;
|
|
119
|
+
readonly id: string;
|
|
120
|
+
/** Wire-encoded values (see `encodeValue`): the patch, the whole row for a create, `{}` for a delete. */
|
|
121
|
+
readonly patch: Record<string, unknown>;
|
|
122
|
+
readonly hlc: Hlc;
|
|
123
|
+
}
|
|
124
|
+
/** How a channel is doing, as `useChannel(...).status()` shows it. */
|
|
125
|
+
type ChannelStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'unsupported';
|
|
126
|
+
interface ChannelState {
|
|
127
|
+
readonly status: ChannelStatus;
|
|
128
|
+
/** Users with at least one tab connected. */
|
|
129
|
+
readonly peers: ReadonlyArray<string>;
|
|
130
|
+
}
|
|
131
|
+
/** What an `accept` rule decides on. */
|
|
132
|
+
interface ChannelOp {
|
|
133
|
+
readonly kind: ChannelOpKind;
|
|
134
|
+
/** The sending user, as the database proved it. */
|
|
135
|
+
readonly from: string;
|
|
136
|
+
readonly table: string;
|
|
137
|
+
readonly id: string;
|
|
138
|
+
/** The row as this client currently shows it, overlay included; undefined for a create. */
|
|
139
|
+
readonly row: Row$1 | undefined;
|
|
140
|
+
/** Decoded field values: the patch, the new row for a create, `{}` for a delete. */
|
|
141
|
+
readonly patch: Readonly<Row$1>;
|
|
142
|
+
}
|
|
143
|
+
type ChannelAcceptRule = (op: ChannelOp) => boolean;
|
|
144
|
+
interface TransportHooks {
|
|
145
|
+
/** This tab, or null while signed out. */
|
|
146
|
+
self(): PeerRef | null;
|
|
147
|
+
onMessage(from: PeerRef, message: unknown): void;
|
|
148
|
+
onPeer(peer: PeerRef, up: boolean): void;
|
|
149
|
+
/** Per user: an attempt is running, a tab is connected, or attempts failed. */
|
|
150
|
+
onStatus(user: string, status: 'connecting' | 'connected' | 'failed'): void;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Moves messages between tabs. WebRTC by default; an app that already has a
|
|
154
|
+
* room (LiveKit, its own relay) can plug that in. `send` is fire and forget.
|
|
155
|
+
*/
|
|
156
|
+
interface ChannelTransport {
|
|
157
|
+
readonly supported: boolean;
|
|
158
|
+
/** Users this tab wants connections to: the union over open channels. */
|
|
159
|
+
want(users: ReadonlySet<string>): void;
|
|
160
|
+
send(to: PeerRef, message: ChannelMessage): void;
|
|
161
|
+
close(): void;
|
|
162
|
+
}
|
|
163
|
+
type ChannelTransportFactory = (hooks: TransportHooks) => ChannelTransport;
|
|
164
|
+
interface ChannelsConfig {
|
|
165
|
+
/**
|
|
166
|
+
* Per table: decide whether to apply a peer's update. Runs after the
|
|
167
|
+
* built-in checks (sender listed by an open channel carrying the table,
|
|
168
|
+
* fields exist and fit).
|
|
169
|
+
* Without a rule every such update is applied.
|
|
170
|
+
*/
|
|
171
|
+
accept?: Record<string, ChannelAcceptRule>;
|
|
172
|
+
/** STUN/TURN servers for the default WebRTC transport. */
|
|
173
|
+
iceServers?: RTCIceServer[];
|
|
174
|
+
/** Replace the WebRTC transport. */
|
|
175
|
+
transport?: ChannelTransportFactory;
|
|
176
|
+
/** How long a peer's persistent value shows without the server confirming it. Default 5000. */
|
|
177
|
+
confirmTimeoutMs?: number;
|
|
178
|
+
}
|
|
179
|
+
/** `table:id` for a record id in any spelling, or null. */
|
|
6
180
|
|
|
181
|
+
/** What a channel carries: the rows of one `@channel` table its filter matches. */
|
|
182
|
+
interface ChannelScope {
|
|
183
|
+
readonly table: string;
|
|
184
|
+
readonly where: ReadonlyArray<WhereNode>;
|
|
185
|
+
}
|
|
186
|
+
/** Whether `row` passes a query's `where` (a conjunction of comparisons and `_or` groups). */
|
|
187
|
+
declare function matchesWhere(where: ReadonlyArray<WhereNode>, row: Row$1): boolean;
|
|
188
|
+
/**
|
|
189
|
+
* The parts of a channel query that do nothing there (a channel only reads
|
|
190
|
+
* the `where`). Not an error, worth a warning unless `.ignoreUnusedArgs()`.
|
|
191
|
+
*/
|
|
192
|
+
declare function unusedArgs(plan: QueryPlan, isOne?: boolean): string[];
|
|
193
|
+
/**
|
|
194
|
+
* A query plan as a channel scope, or an error saying why it cannot be one:
|
|
195
|
+
* the table must be `@channel`, and the filter must be decidable from the
|
|
196
|
+
* row alone (its own fields, literal values, supported operators). Order,
|
|
197
|
+
* limit and projection do not matter to a channel and are ignored.
|
|
198
|
+
*/
|
|
199
|
+
|
|
200
|
+
/** An open channel. `client.channel(...)` wraps it with scoped writes. */
|
|
201
|
+
interface ChannelHandle {
|
|
202
|
+
/** The tables its queries read. */
|
|
203
|
+
tables(): ReadonlyArray<string>;
|
|
204
|
+
state(): ChannelState;
|
|
205
|
+
/** Called at once with the current state, then on every change. */
|
|
206
|
+
subscribe(cb: (s: ChannelState) => void): () => void;
|
|
207
|
+
/** Replace who it connects to (your own id is skipped). */
|
|
208
|
+
setPeers(peers: Iterable<unknown>): void;
|
|
209
|
+
/** Replace what it carries (the queries' filters changed). */
|
|
210
|
+
setScopes(scopes: ReadonlyArray<ChannelScope>): void;
|
|
211
|
+
close(): void;
|
|
212
|
+
}
|
|
213
|
+
/** How peers' creates and deletes reach the store (the runtime's peer sagas). */
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/modules/auth/impersonation.d.ts
|
|
7
216
|
/**
|
|
8
217
|
* Admin impersonation, client side.
|
|
9
218
|
*
|
|
@@ -334,7 +543,7 @@ interface RelationFetch {
|
|
|
334
543
|
table: string;
|
|
335
544
|
matchField: string;
|
|
336
545
|
keys: Id[];
|
|
337
|
-
where?: WhereNode[];
|
|
546
|
+
where?: WhereNode$1[];
|
|
338
547
|
orderBy?: OrderBy;
|
|
339
548
|
select?: string[];
|
|
340
549
|
}
|
|
@@ -491,74 +700,6 @@ type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
|
491
700
|
/** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
|
|
492
701
|
* a guard against a cyclic schema producing unbounded fan-out. */
|
|
493
702
|
//#endregion
|
|
494
|
-
//#region src/services/logger/index.d.ts
|
|
495
|
-
type Logger$1 = Logger;
|
|
496
|
-
/** One line the logger wrote, as the DevTools Logs tab and the MCP read it. */
|
|
497
|
-
interface CapturedLog {
|
|
498
|
-
/** Monotonic for the page's lifetime; a reader resumes after the last one it holds. */
|
|
499
|
-
seq: number;
|
|
500
|
-
time: number;
|
|
501
|
-
/** Pino's numeric level: 10 trace … 60 fatal. */
|
|
502
|
-
level: number;
|
|
503
|
-
msg: string;
|
|
504
|
-
/** The whole log object as JSON (what the console printed), capped at {@link LogTap.LINE_MAX}. */
|
|
505
|
-
line: string;
|
|
506
|
-
}
|
|
507
|
-
interface LogRead {
|
|
508
|
-
entries: CapturedLog[];
|
|
509
|
-
/** `seq` of the newest captured line; pass it back as `after` to resume. */
|
|
510
|
-
head: number;
|
|
511
|
-
/** Lines after `after` the ring overwrote before they were read (cleared ones excluded). */
|
|
512
|
-
dropped: number;
|
|
513
|
-
consoleLevel: Level;
|
|
514
|
-
captureLevel: Level;
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* JSON for a log object that can never throw into the call site: Errors keep
|
|
518
|
-
* their message and stack (plain `JSON.stringify` prints `{}`), bigints become
|
|
519
|
-
* strings, and a cycle prints `[Circular]` instead of throwing.
|
|
520
|
-
*/
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* The page-side log buffer behind the DevTools Logs tab. Always on and
|
|
524
|
-
* bounded: a line costs the JSON the console write produces anyway, and a
|
|
525
|
-
* panel opened after boot still sees the boot.
|
|
526
|
-
*
|
|
527
|
-
* It also owns the logger's level. The console prints at the configured level
|
|
528
|
-
* only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
|
|
529
|
-
* console), live on every child logger, and keeps it for this tab's session so
|
|
530
|
-
* a reload captures its boot at that level too.
|
|
531
|
-
*/
|
|
532
|
-
declare class LogTap {
|
|
533
|
-
readonly consoleLevel: Level;
|
|
534
|
-
static readonly CAPACITY = 500;
|
|
535
|
-
static readonly LINE_MAX = 4000;
|
|
536
|
-
private readonly ring;
|
|
537
|
-
private seq;
|
|
538
|
-
/** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
|
|
539
|
-
private clearedThrough;
|
|
540
|
-
private readonly listeners;
|
|
541
|
-
private readonly loggers;
|
|
542
|
-
private captureLevelValue;
|
|
543
|
-
constructor(consoleLevel: Level);
|
|
544
|
-
/** `seq` of the newest captured line. */
|
|
545
|
-
get head(): number;
|
|
546
|
-
get captureLevel(): Level;
|
|
547
|
-
/** What pino has to produce for both the console and the buffer. */
|
|
548
|
-
get effectiveLevel(): Level;
|
|
549
|
-
/** Whether the console prints a line at this numeric level. */
|
|
550
|
-
printsToConsole(level: number): boolean;
|
|
551
|
-
/** Remember a logger (the root and every child) so a level change reaches it. */
|
|
552
|
-
track(logger: Logger$1): void;
|
|
553
|
-
setCaptureLevel(level: Level): void;
|
|
554
|
-
record(o: Record<string, unknown>, line: string): void;
|
|
555
|
-
/** Lines after `after` (0 = everything still buffered), oldest first. */
|
|
556
|
-
read(after?: number, limit?: number): LogRead;
|
|
557
|
-
clear(): void;
|
|
558
|
-
/** Called synchronously inside the log call: keep it cheap, never log from it. */
|
|
559
|
-
subscribe(cb: () => void): () => void;
|
|
560
|
-
}
|
|
561
|
-
//#endregion
|
|
562
703
|
//#region src/services/database/database.d.ts
|
|
563
704
|
declare abstract class AbstractDatabaseService {
|
|
564
705
|
protected client: Surreal;
|
|
@@ -1649,6 +1790,14 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
1649
1790
|
* out of apps that don't use collaboration. Defaults to `false`.
|
|
1650
1791
|
*/
|
|
1651
1792
|
crdt?: boolean;
|
|
1793
|
+
/**
|
|
1794
|
+
* Peer to peer channels for `-- @channel` tables (see `.channel(peers)` on
|
|
1795
|
+
* a query). `accept` holds the app's rules per table: which peer updates to
|
|
1796
|
+
* apply. `iceServers` configures the default WebRTC transport (a public
|
|
1797
|
+
* STUN server when omitted; add TURN for networks that block direct
|
|
1798
|
+
* connections), `transport` replaces it.
|
|
1799
|
+
*/
|
|
1800
|
+
channels?: ChannelsConfig;
|
|
1652
1801
|
/**
|
|
1653
1802
|
* Cadence (ms) for the `_00_list_ref` poll that catches cross-session
|
|
1654
1803
|
* UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
|
|
@@ -2234,5 +2383,22 @@ interface DebounceOptions {
|
|
|
2234
2383
|
*/
|
|
2235
2384
|
flushOnHide?: boolean;
|
|
2236
2385
|
}
|
|
2386
|
+
/** A query a channel takes its filter from: a builder or a built query, on a `-- @channel` table. */
|
|
2387
|
+
type ChannelQuery<S extends SchemaStructure, T extends TableNames<S>> = QueryBuilder<S, T, any, any, any> | FinalQuery<S, T, any, any, any, any>;
|
|
2388
|
+
/**
|
|
2389
|
+
* An open peer to peer channel (`client.channel(queries, peers)`): its
|
|
2390
|
+
* state, plus writes that go to ITS peers only (when the row matches its
|
|
2391
|
+
* filter). Plain `create` / `update` / `delete` reach the peers of every
|
|
2392
|
+
* open channel whose filter the row matches.
|
|
2393
|
+
*/
|
|
2394
|
+
interface Sp00kyChannel<S extends SchemaStructure, T extends string> extends Omit<ChannelHandle, 'tables' | 'setScopes'> {
|
|
2395
|
+
/** The tables its queries read. */
|
|
2396
|
+
tables(): ReadonlyArray<T>;
|
|
2397
|
+
/** Replace the queries (their filters changed). */
|
|
2398
|
+
setQueries(queries: ChannelQuery<S, T & TableNames<S>> | ReadonlyArray<ChannelQuery<S, T & TableNames<S>>>): void;
|
|
2399
|
+
update<N extends T>(table: N, id: string, data: Partial<TableModel<GetTable<S, N & TableNames<S>>>>, options?: UpdateOptions): Promise<Partial<TableModel<GetTable<S, N & TableNames<S>>>>>;
|
|
2400
|
+
create<N extends T>(id: `${N}:${string}`, data: Partial<TableModel<GetTable<S, N & TableNames<S>>>>): Promise<Partial<TableModel<GetTable<S, N & TableNames<S>>>>>;
|
|
2401
|
+
delete<N extends T>(table: N, id: string): Promise<void>;
|
|
2402
|
+
}
|
|
2237
2403
|
//#endregion
|
|
2238
|
-
export {
|
|
2404
|
+
export { WebPushMessage as $, ChannelState as $t, RecordVersionDiff as A, AuthEventTypes as At, StoreType as B, ImpersonationTimers as Bt, QueryStatus as C, ImpersonationCandidate as Ct, QueryUpdateCallback as D, SignOutHook as Dt, QueryTimings as E, SignOutContext as Et, Sp00kyConfig as F, EventDefinition as Ft, UpdateOptions as G, impersonationFromToken as Gt, SyncHealthConfig as H, SESSION_POLL_MS as Ht, Sp00kyQueryResult as I, EventSystem as It, WebPushConfig as J, ChannelAcceptRule as Jt, PUSH_ENDPOINT_KEY as K, renewDelayMs as Kt, Sp00kyQueryResultPromise as L, IMPERSONATION_ACCESS as Lt, RunOptions as M, RemoteDatabaseService as Mt, ServerViewMeta as N, LocalStore as Nt, ReconnectConfig as O, AuthEventSystem as Ot, Sp00kyChannel as P, SealedQuery as Pt, WebPushInfo as Q, ChannelScope as Qt, StorageHealth as R, ImpersonationInfo as Rt, QueryState as S, AuthService as St, QueryTimeToLive as T, SIGN_OUT_HOOK_TIMEOUT_MS as Tt, SyncHealthStatus as U, TokenClaims as Ut, SyncHealth as V, ORIGIN_TOKEN_KEY as Vt, TimingPhase as W, decodeTokenClaims as Wt, WebPushError as X, ChannelMessage as Xt, WebPushDevice as Y, ChannelHandle as Yt, WebPushErrorCode as Z, ChannelOp as Zt, PreloadRefresh as _, PushPayloadKind as _t, EventSubscriptionOptions as a, TransportHooks as an, WebPushSyncStatus as at, QueryConfigRecord as b, isPushPayload as bt, MATERIALIZATION_SAMPLE_WINDOW as c, LogTap as cn, WebPushUpdateOptions as ct, MutationEvent as d, BridgeTokenMessage as dt, ChannelStatus as en, WebPushMessageInput as et, MutationEventType as f, PUSH_PAYLOAD_VERSION as ft, PreloadOptions as g, PushPayload as gt, PinoTransmit as h, PushOp as ht, EdgeDelta as i, PeerRef as in, WebPushSyncOptions as it, RegistrationTimings as j, createAuthEventSystem as jt, RecordVersionArray as k, AuthEventTypeMap as kt, MembershipOutcome as l, Logger$1 as ln, detectWebPushSupport as lt, PhaseStat as m, PushNotificationAction as mt, ConnectionState as n, ChannelTransportFactory as nn, WebPushSubscribeOptions as nt, InlineRow as o, matchesWhere as on, WebPushUnsubscribeOptions as ot, PersistenceClient as p, PushNotification as pt, PushDeviceKind as q, sameImpersonation as qt, DebounceOptions as r, ChannelsConfig as rn, WebPushSupport as rt, Level$1 as s, unusedArgs as sn, WebPushUnsupportedReason as st, ChannelQuery as t, ChannelTransport as tn, WebPushModule as tt, MutationCallback as u, BridgeSignOutMessage as ut, QueryAuthorityCallback as v, PushUrgency as vt, QueryStatusCallback as w, REFUSAL_RECHECK_MIN_MS as wt, QueryHash as x, ActiveImpersonation as xt, QueryConfig as y, WebPushClientMessage as yt, StorageHealthStatus as z, ImpersonationTimerHost as zt };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.356",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.356",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.356",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -139,6 +139,10 @@ act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb
|
|
|
139
139
|
`db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
|
|
140
140
|
write) and `db.discardFailedMutation(id)`.
|
|
141
141
|
|
|
142
|
+
## Peer to peer channels (experimental)
|
|
143
|
+
|
|
144
|
+
On `-- @channel` tables, `client.channel(query, peers)` opens WebRTC channels to the users listed (both sides must list each other) for the rows the query's `where` matches (checked per row, never registered). Plain `client.update` / `create` / `delete` reach the peers of every open channel whose filter the row matches; the handle's own writes limit that to its peers. `-- @ephemeral` fields go only to peers. Order/limit/select/`.one()` warn unless `.ignoreUnusedArgs()`. Follow status with `ch.subscribe(cb)`, close with `ch.close()`. Config: `channels: { accept, iceServers, transport, confirmTimeoutMs }`. Docs: `/docs/client/channels`.
|
|
145
|
+
|
|
142
146
|
## Authentication
|
|
143
147
|
|
|
144
148
|
See [references/auth.md](references/auth.md) for the full auth API.
|
|
@@ -22,6 +22,7 @@ describe('route', () => {
|
|
|
22
22
|
[{ type: 'GcTick' }, 'dedupe:gc'],
|
|
23
23
|
[{ type: 'Drain' }, 'serial:outbox'],
|
|
24
24
|
[{ type: 'FlushWrite', key: 'k' }, 'serial:outbox-write'],
|
|
25
|
+
[{ type: 'PeerWriteExpired', id: 'thing:1' }, 'serial:peer:thing:1'],
|
|
25
26
|
[{ type: 'FlushPendingWrites' }, undefined],
|
|
26
27
|
[{ type: 'AdoptPendingWrites' }, 'serial:outbox-write'],
|
|
27
28
|
[{ type: 'PollTick' }, 'dedupe:poll'],
|
package/src/client/router.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fetchRows } from '../query/fetch.saga';
|
|
|
7
7
|
import { materialize, streamUpdate } from '../query/materialize.saga';
|
|
8
8
|
import { ackPrune, gcTick, lifecycleTick } from '../query/lifecycle.saga';
|
|
9
9
|
import { drain } from '../mutation/push.saga';
|
|
10
|
-
import { adoptPendingWrites, flushPendingWrites, flushWrite } from '../mutation/write.saga';
|
|
10
|
+
import { adoptPendingWrites, flushPendingWrites, flushWrite, peerWriteExpired } from '../mutation/write.saga';
|
|
11
11
|
import { pollTick } from '../sync/poll.saga';
|
|
12
12
|
import { liveChange, liveStart } from '../sync/live.saga';
|
|
13
13
|
import { connectionChanged, selfHealTick, syncOutcome } from '../sync/connection.saga';
|
|
@@ -64,6 +64,8 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
|
|
|
64
64
|
return { saga: drain(env), lane: serial('outbox') };
|
|
65
65
|
case 'FlushWrite':
|
|
66
66
|
return { saga: flushWrite(env, event.key), lane: serial('outbox-write') };
|
|
67
|
+
case 'PeerWriteExpired':
|
|
68
|
+
return { saga: peerWriteExpired(event.id), lane: serial(`peer:${event.id}`) };
|
|
67
69
|
case 'FlushPendingWrites':
|
|
68
70
|
return { saga: flushPendingWrites() };
|
|
69
71
|
case 'AdoptPendingWrites':
|
|
@@ -339,3 +339,90 @@ describe('Sp00kyClient facade (builder, devtools source, event fan-out)', () =>
|
|
|
339
339
|
expect(tuned.tabRole).toBeNull();
|
|
340
340
|
});
|
|
341
341
|
});
|
|
342
|
+
|
|
343
|
+
describe('Sp00kyClient facade (channels)', () => {
|
|
344
|
+
const chSchema = {
|
|
345
|
+
tables: [
|
|
346
|
+
{ name: 'room', columns: { a: { type: 'number', optional: true }, pos: { type: 'string', optional: true, ephemeral: true } }, channel: true },
|
|
347
|
+
],
|
|
348
|
+
backends: {},
|
|
349
|
+
} as any;
|
|
350
|
+
|
|
351
|
+
function channelClient(channels?: unknown) {
|
|
352
|
+
const services = fakeServiceBundle<any>();
|
|
353
|
+
const a = fakeAdapters({
|
|
354
|
+
local: {
|
|
355
|
+
execute: (async (_q: unknown, vars: any) => ({ id: vars?.id ?? new RecordId('room', '1'), a: 1 })) as any,
|
|
356
|
+
query: (async () => []) as any,
|
|
357
|
+
},
|
|
358
|
+
services: {
|
|
359
|
+
'hint.read': () => 'u1',
|
|
360
|
+
'local.usesSurqlSchema': () => false,
|
|
361
|
+
'auth.restoreSession': async () => null,
|
|
362
|
+
'auth.sessionAuthId': () => null,
|
|
363
|
+
'remote.connect': () => new Promise(() => {}),
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
const runtime = new Runtime({ env: defaultEnv(chSchema, { materializeDebounceMs: 1 }), adapters: a.adapters, logger: services.logger, tabId: 'tab-test' });
|
|
367
|
+
const client = new Sp00kyClient<any>({ ...config, schema: chSchema, channels } as any, { services, runtime });
|
|
368
|
+
return { client, runtime };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
it('opens a channel, keeps ephemeral fields out of writes, scopes its own writes, and wires peer rows to the runtime', async () => {
|
|
372
|
+
let wanted: ReadonlySet<string> = new Set();
|
|
373
|
+
const { client } = channelClient({ transport: () => ({ supported: true, want: (u: ReadonlySet<string>) => (wanted = u), send() {}, close() {} }) });
|
|
374
|
+
await client.init();
|
|
375
|
+
(client as any).channels.setUser('user:a');
|
|
376
|
+
const ch = client.channel((client.query('room', {} as any) as any).where({ a: 1 }), ['user:a', 'user:b']);
|
|
377
|
+
const states: string[] = [];
|
|
378
|
+
ch.subscribe((s) => states.push(s.status));
|
|
379
|
+
expect([...wanted]).toEqual(['user:b']);
|
|
380
|
+
expect(states).toEqual(['connecting']);
|
|
381
|
+
expect(ch.tables()).toEqual(['room']);
|
|
382
|
+
ch.setQueries([(client.query('room', {} as any) as any).limit(3).ignoreUnusedArgs().build()]);
|
|
383
|
+
expect(() => client.channel([{} as any], [])).toThrow(/pass a query/);
|
|
384
|
+
|
|
385
|
+
// Subscriptions see channel values folded in.
|
|
386
|
+
const { hash } = await (client.query('room', {} as any) as any).build().run();
|
|
387
|
+
const stop = await client.subscribe(hash, () => {}, { immediate: true });
|
|
388
|
+
|
|
389
|
+
// Ephemeral-only: no write at all.
|
|
390
|
+
expect(await client.update('room', 'room:1', { pos: '3' })).toEqual({ pos: '3' });
|
|
391
|
+
expect(client.state.outbox).toHaveLength(0);
|
|
392
|
+
// Mixed create: the database gets the persistent part only.
|
|
393
|
+
expect(await client.create('room:2', { a: 1, pos: 'x' })).toMatchObject({ a: 1, pos: 'x' });
|
|
394
|
+
expect(client.state.outbox).toHaveLength(1);
|
|
395
|
+
await expect(client.delete('', 'room:2')).resolves.toBeUndefined();
|
|
396
|
+
|
|
397
|
+
// The channel's own writes.
|
|
398
|
+
expect(await ch.update('room', 'room:1', { pos: '4' } as never)).toEqual({ pos: '4' });
|
|
399
|
+
expect(await ch.create('room:4', { a: 2 } as never)).toBeTruthy();
|
|
400
|
+
await expect(ch.delete('room', 'room:4')).resolves.toBeUndefined();
|
|
401
|
+
expect(() => ch.update('nope' as never, 'nope:1', {} as never)).toThrow(/no query on 'nope'/);
|
|
402
|
+
// The filter is checked against the stored row.
|
|
403
|
+
expect(await (client as any).channels.deps.rows.read('room:1')).toBeNull();
|
|
404
|
+
|
|
405
|
+
const deps = (client as any).channels.deps.rows;
|
|
406
|
+
expect(await deps.create('room', 'room:3', { a: 1 }, 1000)).toBe(true);
|
|
407
|
+
expect(client.state.peerWrites.get('room:3')).toMatchObject({ kind: 'create' });
|
|
408
|
+
expect(await deps.delete('room', 'room:3', 1000)).toBe(true);
|
|
409
|
+
expect(client.state.peerWrites.has('room:3')).toBe(false);
|
|
410
|
+
ch.setPeers(['user:c']);
|
|
411
|
+
expect([...wanted]).toEqual(['user:c']);
|
|
412
|
+
ch.close();
|
|
413
|
+
expect([...wanted]).toEqual([]);
|
|
414
|
+
expect(ch.state().status).toBe('connecting');
|
|
415
|
+
stop();
|
|
416
|
+
await client.close();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('builds the WebRTC transport by default (unsupported without RTCPeerConnection)', async () => {
|
|
420
|
+
const { client } = channelClient();
|
|
421
|
+
await client.init();
|
|
422
|
+
(client as any).channels.setUser('user:a');
|
|
423
|
+
const ch = client.channel((client.query('room', {} as any) as any).build(), ['user:b']);
|
|
424
|
+
expect(ch.state().status).toBe('unsupported');
|
|
425
|
+
ch.close();
|
|
426
|
+
await client.close();
|
|
427
|
+
});
|
|
428
|
+
});
|