@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
|
@@ -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>(
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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,
|
package/src/kernel/events.ts
CHANGED