@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.
@@ -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 { parseParams, encodeRecordId, parseDuration } from './utils/index';
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
- this.cache.setIngestRelay(null);
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 = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
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 = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
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);
@@ -1,125 +1,54 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { RecordId } from 'surrealdb';
3
- import { parseParams, cleanRecord } from './parser';
4
-
5
- describe('parseParams', () => {
6
- it('passes through plain values unchanged', () => {
7
- const schema = {
8
- name: { type: 'string' as const, optional: false },
9
- age: { type: 'number' as const, optional: false },
10
- };
11
- const result = parseParams(schema, { name: 'Alice', age: 30 });
12
- expect(result).toEqual({ name: 'Alice', age: 30 });
13
- });
14
-
15
- it('converts string to RecordId for recordId columns', () => {
16
- const schema = {
17
- owner: { type: 'string' as const, optional: false, recordId: true },
18
- };
19
- const result = parseParams(schema, { owner: 'user:123' });
20
- expect(result.owner).toBeInstanceOf(RecordId);
21
- expect(result.owner.table.toString()).toBe('user');
22
- expect(result.owner.id).toBe('123');
23
- });
24
-
25
- it('passes through existing RecordId for recordId columns', () => {
26
- const schema = {
27
- owner: { type: 'string' as const, optional: false, recordId: true },
28
- };
29
- const rid = new RecordId('user', '456');
30
- const result = parseParams(schema, { owner: rid });
31
- expect(result.owner).toBe(rid);
32
- });
33
-
34
- it('converts string to Date for dateTime columns', () => {
35
- const schema = {
36
- createdAt: { type: 'string' as const, optional: false, dateTime: true },
37
- };
38
- const result = parseParams(schema, { createdAt: '2024-01-01T00:00:00Z' });
39
- expect(result.createdAt).toBeInstanceOf(Date);
40
- expect(result.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
41
- });
42
-
43
- it('converts number (timestamp) to Date for dateTime columns', () => {
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('cleanRecord', () => {
90
- const tableSchema = {
91
- name: { type: 'string' as const, optional: false },
92
- age: { type: 'number' as const, optional: false },
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
  });
@@ -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;