@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.
@@ -2,7 +2,7 @@ import { RecordId } from 'surrealdb';
2
2
  import type { MutationEventType, UpdateOptions } from '../types';
3
3
  import type { Saga } from '../kernel/saga';
4
4
  import { fx } from '../kernel/effects';
5
- import type { ClientState, PendingWrite } from '../state/client-state';
5
+ import type { ClientState, PeerWrite, PendingWrite } from '../state/client-state';
6
6
  import * as R from '../state/reducers';
7
7
  import { parseParams } from '../utils/parser';
8
8
  import { extractTablePart, parseRecordIdString } from '../utils/index';
@@ -17,6 +17,8 @@ import {
17
17
  planDeferredOutboxRowTx,
18
18
  planDeleteTx,
19
19
  planLocalOnlyUpdateTx,
20
+ planPeerCreateTx,
21
+ planPeerUndoTx,
20
22
  planUpdateTx,
21
23
  type LocalTx,
22
24
  type PendingWriteRow,
@@ -266,3 +268,117 @@ export function* adoptPendingWrites(): Saga<void> {
266
268
  if (adopted > 0) yield fx.dispatch({ type: 'Drain' });
267
269
  if (recheckAt !== Infinity) yield fx.timer.set('adopt-writes', recheckAt - now, { type: 'AdoptPendingWrites' });
268
270
  }
271
+
272
+ // ---- channel peers' creates and deletes ------------------------------------------
273
+
274
+ /** The row as the local store holds it, or null: what a channel checks its filter against. */
275
+ export function* readRow(recordId: string): Saga<Record<string, unknown> | null> {
276
+ return yield* readBefore(parseRecordIdString(recordId));
277
+ }
278
+
279
+ export interface PeerWriteInput {
280
+ recordId: string;
281
+ /** The row's fields, for a create. */
282
+ data?: Record<string, unknown>;
283
+ /** How long it shows without the server confirming it. */
284
+ ttlMs: number;
285
+ }
286
+
287
+ /**
288
+ * A channel peer created a row: put it in the store and the circuit like an
289
+ * own optimistic create, minus the outbox entry (the peer pushes it), and
290
+ * show it through the overlay. `_00_rv` stays unset on purpose: the server's
291
+ * body (with its computed fields) still downloads and replaces this one.
292
+ * Refuses an id this client already holds a confirmed row or an own pending
293
+ * write for, so a peer can never overwrite real data. Returns whether it applied.
294
+ */
295
+ export function* peerCreate(env: SagaEnv, input: PeerWriteInput): Saga<boolean> {
296
+ const table = extractTablePart(input.recordId);
297
+ const columns = columnsFor(env, table);
298
+ if (!columns) return false;
299
+ const [confirmed, ownPending, peerPending] = (yield fx.state.read((s) => [
300
+ s.versions.has(input.recordId),
301
+ s.outbox.some((i) => i.recordId === input.recordId),
302
+ s.peerWrites.has(input.recordId),
303
+ ])) as [boolean, boolean, boolean];
304
+ if (confirmed || ownPending || peerPending) return false;
305
+ const rid = parseRecordIdString(input.recordId);
306
+ const existing = yield* readBefore(rid);
307
+ let record = existing;
308
+ if (!existing) {
309
+ const params = parseParams(columns as never, input.data ?? {});
310
+ const tx = planPeerCreateTx({ recordId: rid, data: params });
311
+ try {
312
+ const res = (yield fx.local.execute(tx.query, tx.vars)) as unknown;
313
+ record = res && typeof res === 'object' && !Array.isArray(res) ? (res as Record<string, unknown>) : null;
314
+ } catch (error) {
315
+ yield fx.emit({ type: 'log', level: 'debug', message: 'peer create refused by the local store', data: { recordId: input.recordId, error } });
316
+ return false;
317
+ }
318
+ }
319
+ // An existing unconfirmed row is another tab's copy of the same peer create.
320
+ if (!record) return false;
321
+ try {
322
+ yield fx.ssp.ingest([{ table, op: 'CREATE', id: input.recordId, record: { ...record, _00_rv: 0 } }]);
323
+ } catch (error) {
324
+ yield fx.emit({ type: 'log', level: 'error', message: 'circuit ingest failed after a peer create', data: { recordId: input.recordId, error } });
325
+ }
326
+ const now = (yield fx.now()) as number;
327
+ yield fx.state.update(R.setPeerWrite(input.recordId, { kind: 'create', table, expiresAt: now + input.ttlMs }));
328
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, { type: 'PeerWriteExpired', id: input.recordId });
329
+ return true;
330
+ }
331
+
332
+ /**
333
+ * A channel peer deleted a row: hide it through the overlay, keep it in the
334
+ * store. Undoing is then just forgetting the entry; a confirmed delete has
335
+ * taken the row out of the server's membership by the time it expires.
336
+ */
337
+ export function* peerDelete(env: SagaEnv, input: PeerWriteInput): Saga<boolean> {
338
+ const table = extractTablePart(input.recordId);
339
+ if (!columnsFor(env, table)) return false;
340
+ const [known, confirmed] = (yield fx.state.read((s) => [s.peerWrites.get(input.recordId), s.versions.has(input.recordId)])) as [
341
+ PeerWrite | undefined,
342
+ boolean,
343
+ ];
344
+ if (known?.kind === 'create' && !confirmed) {
345
+ // Deleting a row the same peer created and nobody confirmed: just undo it.
346
+ yield fx.timer.clear(`peer:${input.recordId}`);
347
+ yield* undoPeerCreate(table, input.recordId);
348
+ return true;
349
+ }
350
+ const exists = (yield* readBefore(parseRecordIdString(input.recordId))) !== null;
351
+ if (!exists) return false;
352
+ const now = (yield fx.now()) as number;
353
+ yield fx.state.update(R.setPeerWrite(input.recordId, { kind: 'delete', table, expiresAt: now + input.ttlMs }));
354
+ yield fx.timer.set(`peer:${input.recordId}`, input.ttlMs, { type: 'PeerWriteExpired', id: input.recordId });
355
+ return true;
356
+ }
357
+
358
+ /** Timer target: a peer's write stops showing as unconfirmed. */
359
+ export function* peerWriteExpired(id: string): Saga<void> {
360
+ const [write, confirmed, ownPending] = (yield fx.state.read((s) => [
361
+ s.peerWrites.get(id),
362
+ s.versions.has(id),
363
+ s.outbox.some((i) => i.recordId === id),
364
+ ])) as [PeerWrite | undefined, boolean, boolean];
365
+ if (!write) return;
366
+ if (write.kind === 'create' && !confirmed && !ownPending) {
367
+ yield* undoPeerCreate(write.table, id);
368
+ return;
369
+ }
370
+ yield fx.state.update(R.clearPeerWrite(id));
371
+ }
372
+
373
+ function* undoPeerCreate(table: string, id: string): Saga<void> {
374
+ const rid = parseRecordIdString(id);
375
+ const before = yield* readBefore(rid);
376
+ try {
377
+ const tx = planPeerUndoTx(rid);
378
+ yield fx.local.execute(tx.query, tx.vars);
379
+ if (before) yield fx.ssp.ingest([{ table, op: 'DELETE', id, record: before }]);
380
+ } catch (error) {
381
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not undo an unconfirmed peer create', data: { recordId: id, error } });
382
+ }
383
+ yield fx.state.update(R.clearPeerWrite(id));
384
+ }
@@ -54,6 +54,18 @@ describe('materialize', () => {
54
54
  const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
55
55
  expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:2' }]);
56
56
  });
57
+ it("cold: a peer's unconfirmed delete hides the row the store still holds", async () => {
58
+ const s = buildState(
59
+ [buildEntry({ def: { hash: 'a', plan } })],
60
+ vouch('thing:1', 'thing:2'),
61
+ R.setPeerWrite('thing:2', { kind: 'delete', table: 'thing', expiresAt: 1 })
62
+ );
63
+ const out = await runPure(materialize('a'), {
64
+ state: s,
65
+ handlers: { 'local.select': () => [{ id: 'thing:1' }, { id: 'thing:2' }, { name: 'no id' }] },
66
+ });
67
+ expect(out.state.queries.get('a')!.records).toEqual([{ id: 'thing:1' }, { name: 'no id' }]);
68
+ });
57
69
  it('cold window renders the SSP local window (orphans dropped); live renders membership with the overlay', async () => {
58
70
  const win = buildState(
59
71
  [buildEntry({ def: { hash: 'w', plan: { ...plan, offset: 5 }, surql: 'SELECT * FROM thing LIMIT 5 START 5' }, localArray: [['thing:7', 1], ['thing:8', 1]] })],
@@ -48,7 +48,13 @@ export function* materialize(hash: QueryHash): Saga<void> {
48
48
  let rows: Record<string, unknown>[];
49
49
  try {
50
50
  rows = rowsFromResult(effect, yield effect);
51
- if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
51
+ // A peer's delete leaves the row in the store until it is confirmed, so
52
+ // the predicate scan must drop it here (an own delete removed it already).
53
+ if (ids === null)
54
+ rows = rows.filter((row) => {
55
+ const key = rowKey(row);
56
+ return paints(key) && !(key !== null && ov.deletes.has(key));
57
+ });
52
58
  } catch (error) {
53
59
  yield fx.state.update(R.compose(R.recordError(hash), R.clearDirty(hash)));
54
60
  yield fx.emit({ type: 'log', level: 'warn', message: 'materialize failed', data: { hash, error } });
@@ -103,6 +103,18 @@ export interface PendingWrite {
103
103
  readonly mirrorId: string | null;
104
104
  }
105
105
 
106
+ /**
107
+ * A create or delete a channel peer sent, applied here before the server has
108
+ * it (see `modules/channel`). Shown like an own optimistic write through the
109
+ * overlay; when `expiresAt` passes it is either confirmed (the server's body
110
+ * landed) or undone.
111
+ */
112
+ export interface PeerWrite {
113
+ readonly kind: 'create' | 'delete';
114
+ readonly table: string;
115
+ readonly expiresAt: number;
116
+ }
117
+
106
118
  export interface SyncSlice {
107
119
  readonly health: SyncHealth;
108
120
  readonly consecutiveFailures: number;
@@ -151,6 +163,8 @@ export interface ClientState {
151
163
  readonly views: ReadonlyMap<string, ReadonlyArray<string>>;
152
164
  readonly outbox: ReadonlyArray<OutboxItem>;
153
165
  readonly pendingWrites: ReadonlyMap<string, PendingWrite>;
166
+ /** Channel peers' unconfirmed creates and deletes, by record id. */
167
+ readonly peerWrites: ReadonlyMap<string, PeerWrite>;
154
168
  readonly failedCount: number;
155
169
  /** Queries whose render inputs changed since their last materialization. */
156
170
  readonly dirty: ReadonlySet<QueryHash>;
@@ -196,6 +210,7 @@ export function emptyState(init: { tabId: string }): ClientState {
196
210
  views: new Map(),
197
211
  outbox: [],
198
212
  pendingWrites: new Map(),
213
+ peerWrites: new Map(),
199
214
  failedCount: 0,
200
215
  dirty: new Set(),
201
216
  membershipDirty: new Set(),
@@ -317,3 +317,21 @@ describe('durable view index', () => {
317
317
  expect(reloaded.views).toEqual(new Map([['va', ['t:new']], ['vb', ['t:b']], ['vc', ['t:c']]]));
318
318
  });
319
319
  });
320
+
321
+ describe('peer writes', () => {
322
+ it('set and clear an entry, dirtying the queries of its table', () => {
323
+ const s0 = buildState([buildEntry({ def: { hash: 'q', tableName: 'thing' } })], R.clearDirty('q'));
324
+ const s1 = R.setPeerWrite('thing:1', { kind: 'create', table: 'thing', expiresAt: 5 })(s0);
325
+ expect(s1.peerWrites.get('thing:1')).toEqual({ kind: 'create', table: 'thing', expiresAt: 5 });
326
+ expect(s1.dirty.has('q')).toBe(true);
327
+ const s2 = R.clearPeerWrite('thing:1')(R.clearDirty('q')(s1));
328
+ expect(s2.peerWrites.size).toBe(0);
329
+ expect(s2.dirty.has('q')).toBe(true);
330
+ expect(R.clearPeerWrite('thing:9')(s2)).toBe(s2);
331
+ });
332
+
333
+ it('a bucket switch forgets them', () => {
334
+ const s = R.setPeerWrite('thing:1', { kind: 'delete', table: 'thing', expiresAt: 5 })(buildState());
335
+ expect(R.clearBucketState()(s).peerWrites.size).toBe(0);
336
+ });
337
+ });
@@ -2,6 +2,7 @@ import type { ConnectionState, QueryHash, RecordVersionArray, SyncHealth } from
2
2
  import type {
3
3
  ClientState,
4
4
  OutboxItem,
5
+ PeerWrite,
5
6
  PendingWrite,
6
7
  QueryEntry,
7
8
  QueryTelemetry,
@@ -375,6 +376,25 @@ export const outboxReplace =
375
376
  return next;
376
377
  };
377
378
 
379
+ /** A channel peer's create or delete is now showing; its table re-renders. */
380
+ export const setPeerWrite =
381
+ (id: string, write: PeerWrite): Reducer =>
382
+ (s) => {
383
+ const peerWrites = new Map(s.peerWrites);
384
+ peerWrites.set(id, write);
385
+ return markTableDirty(write.table)({ ...s, peerWrites });
386
+ };
387
+
388
+ export const clearPeerWrite =
389
+ (id: string): Reducer =>
390
+ (s) => {
391
+ const write = s.peerWrites.get(id);
392
+ if (!write) return s;
393
+ const peerWrites = new Map(s.peerWrites);
394
+ peerWrites.delete(id);
395
+ return markTableDirty(write.table)({ ...s, peerWrites });
396
+ };
397
+
378
398
  export const outboxPush =
379
399
  (item: OutboxItem): Reducer =>
380
400
  (s) =>
@@ -505,6 +525,7 @@ export const clearBucketState = (): Reducer => (s) => ({
505
525
  views: new Map(),
506
526
  outbox: [],
507
527
  pendingWrites: new Map(),
528
+ peerWrites: new Map(),
508
529
  dirty: new Set(),
509
530
  membershipDirty: new Set(),
510
531
  membershipReread: new Map(),
@@ -35,6 +35,21 @@ describe('overlay and outbox counts', () => {
35
35
  });
36
36
  });
37
37
 
38
+ describe('overlay with channel peers', () => {
39
+ it('shows peer creates as writes and peer deletes as deletes', () => {
40
+ const s = buildState(
41
+ [],
42
+ R.setPeerWrite('thing:1', { kind: 'create', table: 'thing', expiresAt: 1 }),
43
+ R.setPeerWrite('thing:2', { kind: 'delete', table: 'thing', expiresAt: 1 })
44
+ );
45
+ const o = S.overlay(s);
46
+ expect([...o.writes]).toEqual(['thing:1']);
47
+ expect([...o.deletes]).toEqual(['thing:2']);
48
+ // Not an own write: nothing to push.
49
+ expect(S.pendingMutationCount(s)).toBe(0);
50
+ });
51
+ });
52
+
38
53
  describe('needed / planFetch / settled', () => {
39
54
  const live = { phase: 'live' } as const;
40
55
  it('needed compares membership versions against local versions and skips pending deletes', () => {
@@ -30,9 +30,9 @@ export const queryStatus = (s: ClientState, hash: QueryHash): QueryStatus | unde
30
30
  };
31
31
 
32
32
  export interface Overlay {
33
- /** Ids with an unsynced or just-acked create/update. */
33
+ /** Ids with an unsynced or just-acked create/update (own, or a channel peer's create). */
34
34
  readonly writes: ReadonlySet<string>;
35
- /** Ids with an unsynced or just-acked delete. */
35
+ /** Ids with an unsynced or just-acked delete (own, or a channel peer's). */
36
36
  readonly deletes: ReadonlySet<string>;
37
37
  }
38
38
 
@@ -44,6 +44,11 @@ export function overlay(s: ClientState): Overlay {
44
44
  if (item.type === 'delete') deletes.add(item.recordId);
45
45
  else writes.add(item.recordId);
46
46
  }
47
+ // Channel peers' writes show exactly like our own until confirmed or undone.
48
+ for (const [id, write] of s.peerWrites) {
49
+ if (write.kind === 'delete') deletes.add(id);
50
+ else writes.add(id);
51
+ }
47
52
  return { writes, deletes };
48
53
  }
49
54
 
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import type { ChannelHandle, ChannelsConfig } from './modules/channel/index';
1
2
  import type { ImpersonationBannerConfig } from './modules/auth/impersonation-banner';
2
- import type { RecordId, SchemaStructure, QueryPlan } from '@spooky-sync/query-builder';
3
+ import type { FinalQuery, GetTable, QueryBuilder, RecordId, SchemaStructure, QueryPlan, TableModel, TableNames } from '@spooky-sync/query-builder';
3
4
  import type { Level, LoggerOptions } from 'pino';
4
5
  import type { PushEventOptions } from './events/index';
5
6
  import type { LocalEngineChoice } from './services/database/cache-engine';
@@ -280,6 +281,14 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
280
281
  * out of apps that don't use collaboration. Defaults to `false`.
281
282
  */
282
283
  crdt?: boolean;
284
+ /**
285
+ * Peer to peer channels for `-- @channel` tables (see `.channel(peers)` on
286
+ * a query). `accept` holds the app's rules per table: which peer updates to
287
+ * apply. `iceServers` configures the default WebRTC transport (a public
288
+ * STUN server when omitted; add TURN for networks that block direct
289
+ * connections), `transport` replaces it.
290
+ */
291
+ channels?: ChannelsConfig;
283
292
  /**
284
293
  * Cadence (ms) for the `_00_list_ref` poll that catches cross-session
285
294
  * UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
@@ -891,3 +900,32 @@ export interface DebounceOptions {
891
900
  */
892
901
  flushOnHide?: boolean;
893
902
  }
903
+
904
+ /** A query a channel takes its filter from: a builder or a built query, on a `-- @channel` table. */
905
+ export type ChannelQuery<S extends SchemaStructure, T extends TableNames<S>> =
906
+ | QueryBuilder<S, T, any, any, any>
907
+ | FinalQuery<S, T, any, any, any, any>;
908
+
909
+ /**
910
+ * An open peer to peer channel (`client.channel(queries, peers)`): its
911
+ * state, plus writes that go to ITS peers only (when the row matches its
912
+ * filter). Plain `create` / `update` / `delete` reach the peers of every
913
+ * open channel whose filter the row matches.
914
+ */
915
+ export interface Sp00kyChannel<S extends SchemaStructure, T extends string> extends Omit<ChannelHandle, 'tables' | 'setScopes'> {
916
+ /** The tables its queries read. */
917
+ tables(): ReadonlyArray<T>;
918
+ /** Replace the queries (their filters changed). */
919
+ setQueries(queries: ChannelQuery<S, T & TableNames<S>> | ReadonlyArray<ChannelQuery<S, T & TableNames<S>>>): void;
920
+ update<N extends T>(
921
+ table: N,
922
+ id: string,
923
+ data: Partial<TableModel<GetTable<S, N & TableNames<S>>>>,
924
+ options?: UpdateOptions
925
+ ): Promise<Partial<TableModel<GetTable<S, N & TableNames<S>>>>>;
926
+ create<N extends T>(
927
+ id: `${N}:${string}`,
928
+ data: Partial<TableModel<GetTable<S, N & TableNames<S>>>>
929
+ ): Promise<Partial<TableModel<GetTable<S, N & TableNames<S>>>>>;
930
+ delete<N extends T>(table: N, id: string): Promise<void>;
931
+ }