@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.
@@ -11,7 +11,10 @@ import type {
11
11
  TableModel,
12
12
  TableNames,
13
13
  BucketNames,
14
+ ChannelPeer,
15
+ ChannelTableNames,
14
16
  } from '@spooky-sync/query-builder';
17
+ import type { ChannelQuery } from '../types';
15
18
  import { QueryBuilder } from '@spooky-sync/query-builder';
16
19
  import type {
17
20
  PreloadOptions,
@@ -19,6 +22,7 @@ import type {
19
22
  QueryStatusCallback,
20
23
  QueryTimeToLive,
21
24
  RunOptions,
25
+ Sp00kyChannel,
22
26
  Sp00kyConfig,
23
27
  Sp00kyQueryResultPromise,
24
28
  StorageHealth,
@@ -35,6 +39,8 @@ import type { FeatureFlagOptions, FeatureFlagOverride } from '../modules/feature
35
39
  import { AppReleaseModule, AppReleaseHandle } from '../modules/app-release/index';
36
40
  import type { AppReleaseOptions } from '../modules/app-release/index';
37
41
  import { WebPushModule } from '../modules/web-push/index';
42
+ import { ChannelManager, type ChannelHandle } from '../modules/channel/index';
43
+ import { surrealSignal, webrtcTransport } from '../modules/channel/webrtc';
38
44
  import { DevToolsService } from '../modules/devtools/index';
39
45
  import type { DevToolsQuerySource } from '../modules/devtools/index';
40
46
  import { parseQueryParams, generateId } from '../utils/index';
@@ -45,7 +51,7 @@ import { pendingMutationCount, fetchingQueryCount, phaseTimings, toQueryState }
45
51
  import { defaultEnv, type SagaEnv } from '../query/env';
46
52
  import { registerLocal, type RegisterInput } from '../query/register.saga';
47
53
  import { evictQuery } from '../query/lifecycle.saga';
48
- import { write } from '../mutation/write.saga';
54
+ import { peerCreate, peerDelete, readRow, write } from '../mutation/write.saga';
49
55
  import { discardFailed, listFailed, retryFailed } from '../mutation/tray.saga';
50
56
  import type { FailedMutationRow } from '../mutation/rows';
51
57
  import { buildJobRecord } from '../mutation/jobs';
@@ -78,6 +84,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
78
84
  public readonly streamProcessor: StreamProcessorService;
79
85
  /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
80
86
  public readonly webPush: WebPushModule;
87
+ /** Peer to peer channels of `-- @channel` tables. */
88
+ private readonly channels: ChannelManager;
81
89
 
82
90
  private readonly services: Services<S>;
83
91
  private readonly env: SagaEnv;
@@ -178,6 +186,26 @@ export class Sp00kyClient<S extends SchemaStructure> {
178
186
  });
179
187
  this.disposers.push(this.webPush.attach());
180
188
 
189
+ const channelsConfig = config.channels;
190
+ this.channels = new ChannelManager(config.schema, {
191
+ config: channelsConfig,
192
+ logger: s.logger,
193
+ rows: {
194
+ create: (_table, id, row, ttlMs) => this.runtime.run(peerCreate(this.env, { recordId: id, data: row, ttlMs })),
195
+ delete: (_table, id, ttlMs) => this.runtime.run(peerDelete(this.env, { recordId: id, ttlMs })),
196
+ read: (id) => this.runtime.run(readRow(id)),
197
+ },
198
+ transport:
199
+ channelsConfig?.transport ??
200
+ ((hooks) =>
201
+ webrtcTransport(hooks, {
202
+ signal: surrealSignal(s.remote, s.connectionSupervisor, s.logger),
203
+ iceServers: channelsConfig?.iceServers,
204
+ logger: s.logger,
205
+ })),
206
+ });
207
+ if (this.channels.enabled) this.disposers.push(s.auth.subscribe((userId) => this.channels.setUser(userId)));
208
+
181
209
  const querySource: DevToolsQuerySource = {
182
210
  getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
183
211
  getQueryById: (id) => {
@@ -329,6 +357,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
329
357
  const s = this.services;
330
358
  s.connectionSupervisor.dispose();
331
359
  for (const dispose of this.disposers.splice(0)) dispose();
360
+ this.channels.close();
332
361
  this.runtime.dispose();
333
362
  await this.featureFlags.closeAll();
334
363
  await this.appReleases.closeAll();
@@ -355,7 +384,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
355
384
  options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
356
385
  ttl: QueryTimeToLive = '10m'
357
386
  ): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
358
- return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(this.config.schema, table, async (q) => ({ hash: await this.initQuery(table, q, ttl) }), options);
387
+ return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
388
+ this.config.schema,
389
+ table,
390
+ async (q) => ({ hash: await this.initQuery(table, q, ttl) }),
391
+ options
392
+ );
359
393
  }
360
394
 
361
395
  private registerInput(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): RegisterInput {
@@ -405,7 +439,55 @@ export class Sp00kyClient<S extends SchemaStructure> {
405
439
  }
406
440
 
407
441
  async subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): Promise<() => void> {
408
- return this.runtime.subscribe(queryHash, callback, options);
442
+ if (!this.channels.enabled) return this.runtime.subscribe(queryHash, callback, options);
443
+ // Channel values (peer updates, ephemeral fields) are folded into what
444
+ // subscribers see, never into the store.
445
+ const tap = this.channels.tap(callback);
446
+ const stop = this.runtime.subscribe(queryHash, tap.feed, options);
447
+ return () => {
448
+ stop();
449
+ tap.stop();
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Open a peer to peer channel to `peers` (your own id is skipped; a
455
+ * connection forms only when both sides list each other) for the rows
456
+ * `queries` match, each a query on a `-- @channel` table. Only its `where`
457
+ * counts, evaluated against each row; the query is never registered. Writes
458
+ * to matching rows (plain or the channel's own) reach the peers. Close it
459
+ * when done.
460
+ */
461
+ channel<T extends ChannelTableNames<S>>(
462
+ queries: ChannelQuery<S, T> | ReadonlyArray<ChannelQuery<S, T>>,
463
+ peers: Iterable<ChannelPeer>
464
+ ): Sp00kyChannel<S, T> {
465
+ const handle = this.channels.open(this.channelScopes(queries), peers);
466
+ const scoped = (table: string) => {
467
+ this.channels.assertCarries(handle, table);
468
+ return handle;
469
+ };
470
+ return {
471
+ tables: handle.tables as () => ReadonlyArray<T>,
472
+ state: handle.state,
473
+ subscribe: handle.subscribe,
474
+ setPeers: handle.setPeers,
475
+ setQueries: (next) => handle.setScopes(this.channelScopes(next)),
476
+ close: handle.close,
477
+ update: (table, id, data, options) => this.writeUpdate(table, id, data as Record<string, unknown>, options, scoped(table)) as never,
478
+ create: (id, data) => this.writeCreate(id, data as Record<string, unknown>, scoped(id.slice(0, id.indexOf(':')))) as never,
479
+ delete: (table, id) => this.writeDelete(table, id, scoped(table)),
480
+ };
481
+ }
482
+
483
+ private channelScopes(queries: ChannelQuery<S, any> | ReadonlyArray<ChannelQuery<S, any>>) {
484
+ const list = (Array.isArray(queries) ? queries : [queries]) as ReadonlyArray<ChannelQuery<S, any>>;
485
+ return list.map((q) => {
486
+ const final = 'build' in q && typeof q.build === 'function' ? q.build() : (q as FinalQuery<S, any, any, any, any, any>);
487
+ const plan = final.innerQuery?.selectQuery?.plan;
488
+ if (!plan) throw new Error('channel(): pass a query, e.g. db.query(\'message\').where({ conversation })');
489
+ return this.channels.scope(plan, { isOne: final.isOne, ignoreUnused: final.ignoresUnusedArgs });
490
+ });
409
491
  }
410
492
 
411
493
  deregisterQuery(queryHash: string): void {
@@ -429,16 +511,46 @@ export class Sp00kyClient<S extends SchemaStructure> {
429
511
  // ---- mutations ---------------------------------------------------------------
430
512
 
431
513
  async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
432
- const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data }));
433
- return (out.record ?? data) as T;
514
+ return (await this.writeCreate(id, data)) as T;
515
+ }
516
+
517
+ async update<T extends Record<string, unknown>>(table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
518
+ return (await this.writeUpdate(table, id, data as Record<string, unknown>, options)) as T;
519
+ }
520
+
521
+ async delete(table: string, id: string): Promise<void> {
522
+ await this.writeDelete(table, id);
523
+ }
524
+
525
+ // Peers go first in all three: the local write costs a transaction they need
526
+ // not wait for. `@ephemeral` fields never reach the database.
527
+
528
+ private async writeCreate(id: string, data: Record<string, unknown>, scope?: ChannelHandle): Promise<Record<string, unknown>> {
529
+ const table = id.slice(0, id.indexOf(':'));
530
+ const { persistent, ephemeral } = this.channels.split(table, data);
531
+ this.channels.localCreate(table, id, data, scope);
532
+ const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data: persistent }));
533
+ return { ...(out.record ?? persistent), ...(ephemeral ?? {}) };
434
534
  }
435
535
 
436
- async update<T extends Record<string, unknown>>(_table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
437
- const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: data as Record<string, unknown>, options }));
438
- return (out.record ?? data) as T;
536
+ private async writeUpdate(
537
+ table: string,
538
+ id: string,
539
+ data: Record<string, unknown>,
540
+ options?: UpdateOptions,
541
+ scope?: ChannelHandle
542
+ ): Promise<Record<string, unknown>> {
543
+ const tableName = table || id.slice(0, id.indexOf(':'));
544
+ const { persistent, ephemeral } = this.channels.split(tableName, data);
545
+ this.channels.local(tableName, id, persistent, ephemeral, scope);
546
+ if (ephemeral && Object.keys(persistent).length === 0) return data;
547
+ const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: persistent, options }));
548
+ return { ...(out.record ?? persistent), ...(ephemeral ?? {}) };
439
549
  }
440
550
 
441
- async delete(_table: string, id: string): Promise<void> {
551
+ private async writeDelete(table: string, id: string, scope?: ChannelHandle): Promise<void> {
552
+ // Awaited: the filter is checked against the row, which the delete removes.
553
+ await this.channels.localDelete(table || id.slice(0, id.indexOf(':')), id, scope);
442
554
  await this.runtime.run(write(this.env, { kind: 'delete', recordId: id }));
443
555
  }
444
556
 
package/src/index.ts CHANGED
@@ -2,6 +2,23 @@ export * from './types';
2
2
  export * from './sp00ky';
3
3
  export * from './modules/auth/index';
4
4
  export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
5
+ export {
6
+ type ChannelAcceptRule,
7
+ type ChannelHandle,
8
+ type ChannelScope,
9
+ matchesWhere,
10
+ unusedArgs,
11
+ type ChannelMessage,
12
+ type ChannelOp,
13
+ type ChannelState,
14
+ type ChannelStatus,
15
+ type ChannelTransport,
16
+ type ChannelTransportFactory,
17
+ type ChannelsConfig,
18
+ type PeerRef,
19
+ type TransportHooks,
20
+ } from './modules/channel/index';
21
+ export { webrtcTransport, surrealSignal, type SignalPort, type Signal, type OutgoingSignal } from './modules/channel/webrtc';
5
22
  export {
6
23
  FeatureFlagModule,
7
24
  FeatureFlagHandle,
@@ -23,6 +23,7 @@ export type RuntimeEvent =
23
23
  | { type: 'GcTick' }
24
24
  | { type: 'Drain' }
25
25
  | { type: 'FlushWrite'; key: string }
26
+ | { type: 'PeerWriteExpired'; id: string }
26
27
  | { type: 'FlushPendingWrites' }
27
28
  | { type: 'AdoptPendingWrites' }
28
29
  | { type: 'PollTick' }