@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.
- 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
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/state/reducers.ts
CHANGED
|
@@ -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', () => {
|
package/src/state/selectors.ts
CHANGED
|
@@ -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
|
+
}
|