@spooky-sync/core 0.0.1-canary.355 → 0.0.1-canary.357

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.
@@ -1,4 +1,4 @@
1
- import { m as PinoTransmit } from "../types.js";
1
+ import { h as PinoTransmit } from "../types.js";
2
2
  import { Level } from "pino";
3
3
 
4
4
  //#region src/otel/index.d.ts
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/modules/auth/impersonation.d.ts
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 { WebPushModule as $, RegistrationTimings as A, RemoteDatabaseService as At, SyncHealthConfig as B, ImpersonationTimers as Bt, QueryStatusCallback as C, SIGN_OUT_HOOK_TIMEOUT_MS as Ct, ReconnectConfig as D, AuthEventTypeMap as Dt, QueryUpdateCallback as E, AuthEventSystem as Et, Sp00kyQueryResultPromise as F, EventDefinition as Ft, PushDeviceKind as G, impersonationFromToken as Gt, TimingPhase as H, SESSION_POLL_MS as Ht, StorageHealth as I, EventSystem as It, WebPushError as J, WebPushConfig as K, renewDelayMs as Kt, StorageHealthStatus as L, IMPERSONATION_ACCESS as Lt, ServerViewMeta as M, Logger$1 as Mt, Sp00kyConfig as N, LocalStore as Nt, RecordVersionArray as O, AuthEventTypes as Ot, Sp00kyQueryResult as P, SealedQuery as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationInfo as Rt, QueryStatus as S, REFUSAL_RECHECK_MIN_MS as St, QueryTimings as T, SignOutHook as Tt, UpdateOptions as U, TokenClaims as Ut, SyncHealthStatus as V, ORIGIN_TOKEN_KEY as Vt, PUSH_ENDPOINT_KEY as W, decodeTokenClaims as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, LogTap as jt, RecordVersionDiff as k, createAuthEventSystem as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, sameImpersonation as qt, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutContext as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimerHost as zt };
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.355",
3
+ "version": "0.0.1-canary.357",
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.355",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.355",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.357",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.357",
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'],
@@ -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
+ });