@spooky-sync/core 0.0.1-canary.206 → 0.0.1-canary.208
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/dist/index.d.ts +54 -2
- package/dist/index.js +144 -32
- package/package.json +3 -3
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +26 -4
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/index.ts +35 -15
- package/src/modules/sync/sync.live-removal.test.ts +40 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +71 -6
- package/src/services/tabs/coordinator.test.ts +118 -2
- package/src/services/tabs/coordinator.ts +25 -0
- package/src/services/tabs/protocol.ts +17 -1
- package/src/sp00ky.ts +12 -14
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +30 -1
|
@@ -215,7 +215,13 @@ export type FollowerToLeaderMessage =
|
|
|
215
215
|
* leader should drain it. Idempotent; a new leader's loadFromDatabase is
|
|
216
216
|
* the backstop for a notify lost in a failover window. */
|
|
217
217
|
| { type: 'mutation-enqueued'; mutationId: string }
|
|
218
|
-
| { type: 'request-poll' }
|
|
218
|
+
| { type: 'request-poll' }
|
|
219
|
+
/** An optimistic write this follower committed to the SHARED store and
|
|
220
|
+
* ingested into its own circuit. The leader ingests it (no DB write, the
|
|
221
|
+
* row is already there) and fans it out to every OTHER follower as
|
|
222
|
+
* `ingest-relay`, so a follower's write lands in every tab in one hop
|
|
223
|
+
* instead of after the server round-trip. */
|
|
224
|
+
| { type: 'ingest'; tuples: IngestTuple[] };
|
|
219
225
|
|
|
220
226
|
export type LeaderToFollowerMessage =
|
|
221
227
|
| { type: 'db-ready'; leadershipId: number; bucketId: string; storageHealth: StorageHealth }
|
|
@@ -239,4 +245,14 @@ export type LeaderToFollowerMessage =
|
|
|
239
245
|
recordId: string;
|
|
240
246
|
eventType: 'create' | 'update' | 'delete';
|
|
241
247
|
error: string;
|
|
248
|
+
}
|
|
249
|
+
/** The leader's drain pushed a mutation and deleted its outbox row from the
|
|
250
|
+
* SHARED store. Every follower starts its settled-write grace so a row it
|
|
251
|
+
* was rendering as a pending write does not blink out before its
|
|
252
|
+
* `_00_list_ref` membership arrives. */
|
|
253
|
+
| {
|
|
254
|
+
type: 'mutation-settled';
|
|
255
|
+
mutationId: string;
|
|
256
|
+
recordId: string;
|
|
257
|
+
eventType: 'create' | 'update' | 'delete';
|
|
242
258
|
};
|
package/src/sp00ky.ts
CHANGED
|
@@ -52,7 +52,7 @@ import { AppReleaseModule, AppReleaseHandle } from './modules/app-release/index'
|
|
|
52
52
|
import type { AppReleaseOptions } from './modules/app-release/index';
|
|
53
53
|
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
54
54
|
import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
|
|
55
|
-
import {
|
|
55
|
+
import { parseQueryParams, encodeRecordId, parseDuration } from './utils/index';
|
|
56
56
|
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
57
57
|
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
58
58
|
import { detectSharedTabsSupport } from './services/tabs/support';
|
|
@@ -673,20 +673,18 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
673
673
|
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
674
674
|
becomeSyncFollower: (forwarder) => {
|
|
675
675
|
this.streamProcessor.setPersistenceEnabled(false);
|
|
676
|
-
|
|
676
|
+
// A follower's own mutations go to the leader, which ingests them and
|
|
677
|
+
// fans them out to the other followers: one hop, no server round-trip.
|
|
678
|
+
// Only the mutation path relays; this tab's sync fetches are the
|
|
679
|
+
// leader's data coming back and must not be re-broadcast.
|
|
680
|
+
this.cache.setIngestRelay((tuples) => forwarder.ingest(tuples), {
|
|
681
|
+
localWritesOnly: true,
|
|
682
|
+
});
|
|
677
683
|
this.sync.setTabContext('follower', tabId);
|
|
678
684
|
this.dataModule.setTabId(tabId);
|
|
685
|
+
// Installs the whole syncPort handler: ingest-relay, list_ref relay,
|
|
686
|
+
// settled writes, rollbacks.
|
|
679
687
|
this.sync.demoteToFollower(forwarder);
|
|
680
|
-
// demoteToFollower installed the sync-level handler (list_ref relay,
|
|
681
|
-
// rollbacks); layer the cache-level ingest relay in front of it.
|
|
682
|
-
const inner = forwarder.onLeaderMessage;
|
|
683
|
-
forwarder.onLeaderMessage = (msg) => {
|
|
684
|
-
if (msg.type === 'ingest-relay') {
|
|
685
|
-
this.cache.applyRelayedIngest(msg.tuples);
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
inner?.(msg);
|
|
689
|
-
};
|
|
690
688
|
},
|
|
691
689
|
becomeSyncSolo: () => {
|
|
692
690
|
this.streamProcessor.setPersistenceEnabled(true);
|
|
@@ -1360,7 +1358,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1360
1358
|
throw new Error(`Table ${table} not found`);
|
|
1361
1359
|
}
|
|
1362
1360
|
|
|
1363
|
-
const params =
|
|
1361
|
+
const params = parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
1364
1362
|
const hash = await this.dataModule.query(
|
|
1365
1363
|
table,
|
|
1366
1364
|
q.selectQuery.query,
|
|
@@ -1472,7 +1470,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1472
1470
|
if (!tableSchema) {
|
|
1473
1471
|
throw new Error(`Table ${tableName} not found`);
|
|
1474
1472
|
}
|
|
1475
|
-
const params =
|
|
1473
|
+
const params = parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
1476
1474
|
const hashKey = String(q.hash);
|
|
1477
1475
|
|
|
1478
1476
|
const marker = await this.dataModule.getPreloadMarker(hashKey);
|
package/src/utils/parser.test.ts
CHANGED
|
@@ -1,125 +1,54 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import { RecordId } from '
|
|
3
|
-
import { parseParams,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const schema = {
|
|
45
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
46
|
-
};
|
|
47
|
-
const ts = 1704067200000; // 2024-01-01T00:00:00.000Z
|
|
48
|
-
const result = parseParams(schema, { createdAt: ts });
|
|
49
|
-
expect(result.createdAt).toBeInstanceOf(Date);
|
|
50
|
-
expect(result.createdAt.getTime()).toBe(ts);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('passes through existing Date for dateTime columns', () => {
|
|
54
|
-
const schema = {
|
|
55
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
56
|
-
};
|
|
57
|
-
const date = new Date('2024-01-01');
|
|
58
|
-
const result = parseParams(schema, { createdAt: date });
|
|
59
|
-
expect(result.createdAt).toBe(date);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('skips undefined values', () => {
|
|
63
|
-
const schema = {
|
|
64
|
-
name: { type: 'string' as const, optional: false },
|
|
65
|
-
age: { type: 'number' as const, optional: true },
|
|
66
|
-
};
|
|
67
|
-
const result = parseParams(schema, { name: 'Alice', age: undefined });
|
|
68
|
-
expect(result).toEqual({ name: 'Alice' });
|
|
69
|
-
expect('age' in result).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('throws on invalid recordId value', () => {
|
|
73
|
-
const schema = {
|
|
74
|
-
owner: { type: 'string' as const, optional: false, recordId: true },
|
|
75
|
-
};
|
|
76
|
-
expect(() => parseParams(schema, { owner: 12345 })).toThrow('Invalid value for owner');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('throws on invalid dateTime value', () => {
|
|
80
|
-
const schema = {
|
|
81
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
82
|
-
};
|
|
83
|
-
expect(() => parseParams(schema, { createdAt: true })).toThrow(
|
|
84
|
-
'Invalid value for createdAt'
|
|
85
|
-
);
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from '@spooky-sync/query-builder';
|
|
3
|
+
import { parseParams, parseQueryParams } from './parser';
|
|
4
|
+
|
|
5
|
+
const columns = {
|
|
6
|
+
white: { recordId: true },
|
|
7
|
+
black: { recordId: true },
|
|
8
|
+
owner: { recordId: true },
|
|
9
|
+
created_at: { dateTime: true },
|
|
10
|
+
result: {},
|
|
11
|
+
} as any;
|
|
12
|
+
|
|
13
|
+
describe('parseQueryParams', () => {
|
|
14
|
+
// The regression this exists for: an `_or` group binds under synthetic names,
|
|
15
|
+
// and dropping them left `(white = $white__or0 OR black = $black__or1)` with
|
|
16
|
+
// nothing bound, so every OR query registered and matched no rows at all.
|
|
17
|
+
it('keeps an _or branch param and types it through its field', () => {
|
|
18
|
+
const parsed = parseQueryParams(columns, {
|
|
19
|
+
owner: 'user:abc',
|
|
20
|
+
white__or0: 'player_name:PN_1',
|
|
21
|
+
black__or1: 'player_name:PN_1',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(Object.keys(parsed).sort()).toEqual(['black__or1', 'owner', 'white__or0']);
|
|
25
|
+
expect(parsed.white__or0).toBeInstanceOf(RecordId);
|
|
26
|
+
expect(String(parsed.white__or0)).toBe('player_name:PN_1');
|
|
27
|
+
expect(parsed.black__or1).toBeInstanceOf(RecordId);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes a param it cannot resolve through untouched instead of dropping it', () => {
|
|
31
|
+
const parsed = parseQueryParams(columns, { in: 'anything', limit: 30 });
|
|
32
|
+
expect(parsed).toEqual({ in: 'anything', limit: 30 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('still types and keeps plain column params, and skips undefined', () => {
|
|
36
|
+
const parsed = parseQueryParams(columns, {
|
|
37
|
+
owner: 'user:abc',
|
|
38
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
result: undefined,
|
|
40
|
+
});
|
|
41
|
+
expect(parsed.owner).toBeInstanceOf(RecordId);
|
|
42
|
+
expect(parsed.created_at).toBeInstanceOf(Date);
|
|
43
|
+
expect('result' in parsed).toBe(false);
|
|
86
44
|
});
|
|
87
45
|
});
|
|
88
46
|
|
|
89
|
-
describe('
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
it('keeps schema fields and strips non-schema fields', () => {
|
|
96
|
-
const record = { id: 'user:1', name: 'Alice', age: 30, _internal: true, computed_score: 99 };
|
|
97
|
-
const result = cleanRecord(tableSchema, record);
|
|
98
|
-
expect(result).toEqual({ id: 'user:1', name: 'Alice', age: 30 });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('always preserves id even though it is not in schema', () => {
|
|
102
|
-
const record = { id: 'user:2', extra: 'gone' };
|
|
103
|
-
const result = cleanRecord(tableSchema, record);
|
|
104
|
-
expect(result).toEqual({ id: 'user:2' });
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('does not coerce or transform values', () => {
|
|
108
|
-
const date = new Date('2024-01-01');
|
|
109
|
-
const schema = { created: { type: 'string' as const, optional: false, dateTime: true } };
|
|
110
|
-
const record = { id: 'x:1', created: date };
|
|
111
|
-
const result = cleanRecord(schema, record);
|
|
112
|
-
expect(result.created).toBe(date);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('returns only id when record has no schema fields', () => {
|
|
116
|
-
const record = { id: 'user:3', unknown1: 'a', unknown2: 'b' };
|
|
117
|
-
const result = cleanRecord(tableSchema, record);
|
|
118
|
-
expect(result).toEqual({ id: 'user:3' });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('handles empty record', () => {
|
|
122
|
-
const result = cleanRecord(tableSchema, {});
|
|
123
|
-
expect(result).toEqual({});
|
|
47
|
+
describe('parseParams', () => {
|
|
48
|
+
// Records keep the old behaviour: a field the schema does not know is dropped
|
|
49
|
+
// rather than written.
|
|
50
|
+
it('drops a field that is not a column', () => {
|
|
51
|
+
const parsed = parseParams(columns, { result: '1-0', nonsense: 'x', white__or0: 'y' });
|
|
52
|
+
expect(parsed).toEqual({ result: '1-0' });
|
|
124
53
|
});
|
|
125
54
|
});
|
package/src/utils/parser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ColumnSchema} from '@spooky-sync/query-builder';
|
|
2
|
-
import { RecordId } from '@spooky-sync/query-builder';
|
|
2
|
+
import { RecordId, baseFieldOfParam } from '@spooky-sync/query-builder';
|
|
3
3
|
import { parseRecordIdString } from './index';
|
|
4
4
|
import { DateTime } from 'surrealdb';
|
|
5
5
|
|
|
@@ -16,6 +16,10 @@ export function cleanRecord(
|
|
|
16
16
|
return cleaned;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Parse a RECORD's fields against the table schema. Anything the schema does not
|
|
21
|
+
* know is dropped, which is what keeps a stray field out of a write.
|
|
22
|
+
*/
|
|
19
23
|
export function parseParams(
|
|
20
24
|
tableSchema: Record<string, ColumnSchema>,
|
|
21
25
|
params: Record<string, any>
|
|
@@ -31,6 +35,31 @@ export function parseParams(
|
|
|
31
35
|
return parsedParams;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Parse a QUERY's params. Unlike a record's fields, a param name is not always a
|
|
40
|
+
* column: an `_or` branch binds under a synthetic `white__or0` (see
|
|
41
|
+
* `orParamName` in @spooky-sync/query-builder), and the surql that references it
|
|
42
|
+
* is already written. So resolve the column through the synthetic name, and keep
|
|
43
|
+
* a param we cannot type rather than dropping it.
|
|
44
|
+
*
|
|
45
|
+
* Dropping was the bug: `parseParams` kept only column-named params, so every
|
|
46
|
+
* `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
|
|
47
|
+
* silently (rowCount 0, errorCount 0).
|
|
48
|
+
*/
|
|
49
|
+
export function parseQueryParams(
|
|
50
|
+
tableSchema: Record<string, ColumnSchema>,
|
|
51
|
+
params: Record<string, any>
|
|
52
|
+
) {
|
|
53
|
+
const parsedParams: Record<string, any> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(params)) {
|
|
55
|
+
if (value === undefined) continue;
|
|
56
|
+
const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
|
|
57
|
+
parsedParams[key] = column ? parseValue(key, column, value) : value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parsedParams;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
function parseValue(name: string, column: ColumnSchema, value: any) {
|
|
35
64
|
if (column.recordId) {
|
|
36
65
|
if (value instanceof RecordId) return value;
|