@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.105
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 +180 -3
- package/dist/index.js +2212 -1620
- package/dist/types.d.ts +15 -4
- package/package.json +3 -3
- package/src/modules/cache/index.ts +35 -2
- package/src/modules/crdt/crdt-field.ts +9 -2
- package/src/modules/crdt/index.ts +7 -2
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.status.test.ts +143 -2
- package/src/modules/data/index.ts +229 -13
- package/src/modules/devtools/index.ts +8 -5
- package/src/modules/ref-tables.test.ts +26 -1
- package/src/modules/ref-tables.ts +19 -0
- package/src/modules/sync/queue/queue-down.ts +6 -0
- package/src/modules/sync/queue/queue-up.ts +14 -0
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +33 -4
- package/src/modules/sync/sync.ts +76 -5
- package/src/services/database/local-migrator.ts +6 -6
- package/src/services/database/local.test.ts +31 -0
- package/src/services/database/local.ts +298 -72
- package/src/services/stream-processor/index.ts +48 -2
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/sp00ky.auth-order.test.ts +27 -0
- package/src/sp00ky.ts +132 -2
- package/src/types.ts +15 -4
package/dist/types.d.ts
CHANGED
|
@@ -403,10 +403,13 @@ type QueryConfigRecord = QueryConfig & {
|
|
|
403
403
|
};
|
|
404
404
|
/**
|
|
405
405
|
* Runtime fetch status of a live query.
|
|
406
|
-
* - `idle`: not currently fetching
|
|
407
|
-
*
|
|
408
|
-
* query
|
|
409
|
-
*
|
|
406
|
+
* - `idle`: registered, initial sync completed, and not currently fetching
|
|
407
|
+
* missing records — the materialized rows are authoritative (a windowed
|
|
408
|
+
* query's short result really is the end of the list).
|
|
409
|
+
* - `fetching`: the query is registering (a query is born `fetching` until its
|
|
410
|
+
* initial remote sync completes) or the sync engine is fetching/ingesting
|
|
411
|
+
* missing records for it. Any pending debounced result is flushed BEFORE the
|
|
412
|
+
* flip back to `idle`, so idle status never races ahead of the rows.
|
|
410
413
|
*/
|
|
411
414
|
type QueryStatus = 'idle' | 'fetching';
|
|
412
415
|
/**
|
|
@@ -420,12 +423,20 @@ interface QueryState {
|
|
|
420
423
|
/** Set once `applyHydration` has run for this query, so the cold instant-hydrate
|
|
421
424
|
* path fires at most once per query (see DataModule.isCold/applyHydration). */
|
|
422
425
|
hydrated?: boolean;
|
|
426
|
+
/** Set once `notifyQuerySynced` has emitted for this registration lifetime.
|
|
427
|
+
* Ephemeral (unlike the persisted `updateCount`), so a re-registered query
|
|
428
|
+
* always emits at least once even when its records are unchanged — otherwise
|
|
429
|
+
* an empty re-registered window would never notify and stay "loading". */
|
|
430
|
+
syncNotified?: boolean;
|
|
423
431
|
/** Timer for TTL expiration. */
|
|
424
432
|
ttlTimer: NodeJS.Timeout | null;
|
|
425
433
|
/** TTL duration in milliseconds. */
|
|
426
434
|
ttlDurationMs: number;
|
|
427
435
|
/** Number of times the query has been updated. */
|
|
428
436
|
updateCount: number;
|
|
437
|
+
/** Timestamp (ms) of the last user-visible update, or null before the first
|
|
438
|
+
* one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
|
|
439
|
+
lastUpdatedAt: number | null;
|
|
429
440
|
/**
|
|
430
441
|
* Rolling window of the most recent materialization-step latencies (ms).
|
|
431
442
|
* Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.105",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.105",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.105",
|
|
64
64
|
"@surrealdb/wasm": "^3.0.3",
|
|
65
65
|
"fast-json-patch": "^3.1.1",
|
|
66
66
|
"loro-crdt": "^1.5.6",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LocalDatabaseService } from '../../services/database/index';
|
|
2
|
+
import { StaleEpochError } from '../../services/database/index';
|
|
2
3
|
import type {
|
|
3
4
|
StreamProcessorService,
|
|
4
5
|
StreamUpdate,
|
|
@@ -54,6 +55,12 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
54
55
|
return this.versionLookups[recordId] ?? 0;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** Drop the version cache on a bucket switch — a stale version would make
|
|
59
|
+
* the sync diff skip fetching a body the new bucket legitimately needs. */
|
|
60
|
+
public clearVersionLookups(): void {
|
|
61
|
+
this.versionLookups = {};
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/**
|
|
58
65
|
* Save a single record to local DB and ingest into DBSP
|
|
59
66
|
* Used by mutations (create/update)
|
|
@@ -70,6 +77,13 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
70
77
|
async saveBatch(records: CacheRecord[], skipDbInsert: boolean = false): Promise<void> {
|
|
71
78
|
if (records.length === 0) return;
|
|
72
79
|
|
|
80
|
+
// Fence against bucket switches: this batch's records were derived from
|
|
81
|
+
// reads against the CURRENT store/user. If a switch lands while we await
|
|
82
|
+
// the (gated) local write, the write throws StaleEpochError and the whole
|
|
83
|
+
// batch — including the SSP ingest — is dropped: the new bucket re-syncs
|
|
84
|
+
// its own data from the server.
|
|
85
|
+
const epoch = this.local.epoch;
|
|
86
|
+
|
|
73
87
|
this.logger.debug(
|
|
74
88
|
{
|
|
75
89
|
count: records.length,
|
|
@@ -116,9 +130,12 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
116
130
|
{} as Record<string, any>
|
|
117
131
|
);
|
|
118
132
|
|
|
119
|
-
await this.local.execute(query, params);
|
|
133
|
+
await this.local.execute(query, params, { epoch });
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
// Late fence for the skipDbInsert path (no gated write above to trip on).
|
|
137
|
+
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
138
|
+
|
|
122
139
|
// 2. Bulk ingest into DBSP (use populatedRecords which has _00_rv set).
|
|
123
140
|
// ingestMany coalesces the per-record stream updates into a single
|
|
124
141
|
// notification per affected query — the UI then updates once, after the
|
|
@@ -135,6 +152,13 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
135
152
|
'Batch saved successfully'
|
|
136
153
|
);
|
|
137
154
|
} catch (err) {
|
|
155
|
+
if (err instanceof StaleEpochError) {
|
|
156
|
+
this.logger.debug(
|
|
157
|
+
{ count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
|
|
158
|
+
'Dropped batch from before a bucket switch'
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
138
162
|
this.logger.error(
|
|
139
163
|
{ err, count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
|
|
140
164
|
'Failed to save batch'
|
|
@@ -152,11 +176,13 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
152
176
|
'Deleting record'
|
|
153
177
|
);
|
|
154
178
|
|
|
179
|
+
const epoch = this.local.epoch;
|
|
155
180
|
try {
|
|
156
181
|
// 1. Delete from local database
|
|
157
182
|
if (!skipDbDelete) {
|
|
158
|
-
await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
|
|
183
|
+
await this.local.query('DELETE $id', { id: parseRecordIdString(id) }, { epoch });
|
|
159
184
|
}
|
|
185
|
+
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
160
186
|
|
|
161
187
|
// 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
|
|
162
188
|
delete this.versionLookups[id];
|
|
@@ -167,6 +193,13 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
167
193
|
'Record deleted successfully'
|
|
168
194
|
);
|
|
169
195
|
} catch (err) {
|
|
196
|
+
if (err instanceof StaleEpochError) {
|
|
197
|
+
this.logger.debug(
|
|
198
|
+
{ table, id, Category: 'sp00ky-client::CacheModule::delete' },
|
|
199
|
+
'Dropped delete from before a bucket switch'
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
170
203
|
this.logger.error(
|
|
171
204
|
{ err, table, id, Category: 'sp00ky-client::CacheModule::delete' },
|
|
172
205
|
'Failed to delete record'
|
|
@@ -124,10 +124,17 @@ export class CrdtField {
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Stop syncing this field. Flushes one final remote push by default so the
|
|
129
|
+
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
130
|
+
* the remote session already belongs to the NEXT user, and pushing this
|
|
131
|
+
* (previous user's) snapshot under it would clobber the record remotely.
|
|
132
|
+
*/
|
|
133
|
+
stopSync(options: { flush?: boolean } = {}): void {
|
|
134
|
+
const flush = options.flush ?? true;
|
|
128
135
|
if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
|
|
129
136
|
if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
|
|
130
|
-
if (this.remote && this.recordId) { void this.pushToRemote(); }
|
|
137
|
+
if (flush && this.remote && this.recordId) { void this.pushToRemote(); }
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
importRemote(state: Uint8Array): void {
|
|
@@ -147,9 +147,14 @@ export class CrdtManager {
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Close every open field + table LIVE. Fields flush a final remote push by
|
|
152
|
+
* default; pass `{ flush: false }` on a bucket switch, where that flush
|
|
153
|
+
* would push the previous user's snapshot under the next user's session.
|
|
154
|
+
*/
|
|
155
|
+
closeAll(options: { flush?: boolean } = {}): void {
|
|
151
156
|
for (const [_, field] of this.fields) {
|
|
152
|
-
field.stopSync();
|
|
157
|
+
field.stopSync(options);
|
|
153
158
|
}
|
|
154
159
|
this.fields.clear();
|
|
155
160
|
for (const table of Array.from(this.liveByTable.keys())) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
import type { QueryState } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for the local-bucket-switch surface of DataModule:
|
|
8
|
+
* - `quiesce()` disarms every debounce + TTL timer;
|
|
9
|
+
* - `rebindAfterBucketSwitch()` keeps query hashes (subscriptions stay
|
|
10
|
+
* attached), resets sync state, notifies subscribers with the emptied
|
|
11
|
+
* records so the previous user's rows leave the UI, re-registers the SSP
|
|
12
|
+
* view, and returns the hashes for remote re-registration;
|
|
13
|
+
* - stale-epoch stream updates are dropped instead of applied.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function makeLogger(): any {
|
|
17
|
+
const noop = () => {};
|
|
18
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
19
|
+
logger.child = () => logger;
|
|
20
|
+
return logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeQueryState(hash: string, records: Record<string, any>[]): QueryState {
|
|
24
|
+
return {
|
|
25
|
+
config: {
|
|
26
|
+
id: new RecordId('_00_query', hash),
|
|
27
|
+
surql: 'SELECT * FROM user',
|
|
28
|
+
params: {},
|
|
29
|
+
localArray: [['user:a', 1]],
|
|
30
|
+
remoteArray: [['user:a', 1]],
|
|
31
|
+
ttl: '10m',
|
|
32
|
+
lastActiveAt: new Date(),
|
|
33
|
+
tableName: 'user',
|
|
34
|
+
},
|
|
35
|
+
records,
|
|
36
|
+
hydrated: true,
|
|
37
|
+
ttlTimer: null,
|
|
38
|
+
ttlDurationMs: 600_000,
|
|
39
|
+
updateCount: 3,
|
|
40
|
+
lastUpdatedAt: null,
|
|
41
|
+
materializationSamples: [],
|
|
42
|
+
lastIngestLatencyMs: null,
|
|
43
|
+
errorCount: 0,
|
|
44
|
+
status: 'idle',
|
|
45
|
+
phaseSamples: {},
|
|
46
|
+
phaseLast: {},
|
|
47
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeHarness(epoch = 0) {
|
|
52
|
+
const localQueries: Array<{ query: unknown; vars: unknown; opts: unknown }> = [];
|
|
53
|
+
const local: any = {
|
|
54
|
+
epoch,
|
|
55
|
+
query: vi.fn(async (query: unknown, vars?: unknown, opts?: unknown) => {
|
|
56
|
+
localQueries.push({ query, vars, opts });
|
|
57
|
+
return [[]];
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
const cache: any = {
|
|
61
|
+
registerQuery: vi.fn(() => ({ localArray: [] })),
|
|
62
|
+
};
|
|
63
|
+
const dm = new DataModule(cache, local, { tables: [] } as any, makeLogger(), 100);
|
|
64
|
+
return { dm, local, cache, localQueries };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('DataModule.quiesce', () => {
|
|
68
|
+
it('clears debounce and TTL timers', () => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
try {
|
|
71
|
+
const { dm } = makeHarness();
|
|
72
|
+
const qs = makeQueryState('h1', []);
|
|
73
|
+
qs.ttlTimer = setTimeout(() => {}, 60_000);
|
|
74
|
+
(dm as any).activeQueries.set('h1', qs);
|
|
75
|
+
(dm as any).debounceTimers.set('h1', setTimeout(() => {}, 60_000));
|
|
76
|
+
|
|
77
|
+
dm.quiesce();
|
|
78
|
+
|
|
79
|
+
expect(qs.ttlTimer).toBeNull();
|
|
80
|
+
expect((dm as any).debounceTimers.size).toBe(0);
|
|
81
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
82
|
+
} finally {
|
|
83
|
+
vi.useRealTimers();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('DataModule.rebindAfterBucketSwitch', () => {
|
|
89
|
+
let harness: ReturnType<typeof makeHarness>;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
harness = makeHarness();
|
|
93
|
+
(harness.dm as any).activeQueries.set(
|
|
94
|
+
'h1',
|
|
95
|
+
makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }])
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps the hash, resets sync state, and notifies subscribers with empty records', async () => {
|
|
100
|
+
const { dm, cache } = harness;
|
|
101
|
+
const emissions: unknown[][] = [];
|
|
102
|
+
dm.subscribe('h1', (records) => emissions.push(records as unknown[]));
|
|
103
|
+
|
|
104
|
+
const hashes = await dm.rebindAfterBucketSwitch();
|
|
105
|
+
|
|
106
|
+
expect(hashes).toEqual(['h1']);
|
|
107
|
+
const qs = (dm as any).activeQueries.get('h1') as QueryState;
|
|
108
|
+
expect(qs.records).toEqual([]);
|
|
109
|
+
expect(qs.config.localArray).toEqual([]);
|
|
110
|
+
expect(qs.config.remoteArray).toEqual([]);
|
|
111
|
+
expect(qs.hydrated).toBe(false);
|
|
112
|
+
expect(qs.status).toBe('fetching');
|
|
113
|
+
// Subscribers saw the previous user's rows drop out.
|
|
114
|
+
expect(emissions).toEqual([[]]);
|
|
115
|
+
// The SSP view was re-registered (on the fresh, post-reset processor).
|
|
116
|
+
expect(cache.registerQuery).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ queryHash: 'h1', surql: 'SELECT * FROM user' })
|
|
118
|
+
);
|
|
119
|
+
// The TTL heartbeat is re-armed.
|
|
120
|
+
expect(qs.ttlTimer).not.toBeNull();
|
|
121
|
+
if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('stale-epoch stream updates', () => {
|
|
126
|
+
it('drops an update whose chain started before a bucket switch', async () => {
|
|
127
|
+
const { dm, local } = makeHarness();
|
|
128
|
+
const qs = makeQueryState('h1', [{ id: 'user:a' }]);
|
|
129
|
+
(dm as any).activeQueries.set('h1', qs);
|
|
130
|
+
|
|
131
|
+
// The materialize read happens, then the epoch moves (bucket switched).
|
|
132
|
+
local.query.mockImplementation(async () => {
|
|
133
|
+
local.epoch = 1;
|
|
134
|
+
return [[{ id: 'user:b', name: 'other user row' }]];
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await (dm as any).processStreamUpdate({
|
|
138
|
+
queryHash: 'h1',
|
|
139
|
+
localArray: [['user:b', 1]],
|
|
140
|
+
op: 'CREATE',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Neither the records nor the persisted arrays moved.
|
|
144
|
+
expect(qs.records).toEqual([{ id: 'user:a' }]);
|
|
145
|
+
expect(qs.config.localArray).toEqual([['user:a', 1]]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
3
|
import { DataModule } from './index';
|
|
4
4
|
import type { QueryState, QueryStatus } from '../../types';
|
|
@@ -6,7 +6,9 @@ import type { QueryState, QueryStatus } from '../../types';
|
|
|
6
6
|
/**
|
|
7
7
|
* Tests for the per-query fetch status (idle/fetching) added to DataModule:
|
|
8
8
|
* setQueryStatus updates the query object and notifies both the DevTools
|
|
9
|
-
* observer hook and subscribeStatus listeners
|
|
9
|
+
* observer hook and subscribeStatus listeners; beginFetching/endFetching
|
|
10
|
+
* refcount overlapping fetch cycles; flushPendingStreamUpdate lands the
|
|
11
|
+
* debounced result before a query flips back to idle.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
function makeLogger(): any {
|
|
@@ -42,10 +44,14 @@ function makeQueryState(hash: string): QueryState {
|
|
|
42
44
|
ttlTimer: null,
|
|
43
45
|
ttlDurationMs: 0,
|
|
44
46
|
updateCount: 0,
|
|
47
|
+
lastUpdatedAt: null,
|
|
45
48
|
materializationSamples: [],
|
|
46
49
|
lastIngestLatencyMs: null,
|
|
47
50
|
errorCount: 0,
|
|
48
51
|
status: 'idle',
|
|
52
|
+
phaseSamples: {},
|
|
53
|
+
phaseLast: {},
|
|
54
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -106,3 +112,138 @@ describe('DataModule query fetch status', () => {
|
|
|
106
112
|
expect(seen).toEqual(['fetching']);
|
|
107
113
|
});
|
|
108
114
|
});
|
|
115
|
+
|
|
116
|
+
describe('DataModule beginFetching/endFetching refcount', () => {
|
|
117
|
+
let dm: DataModule<any>;
|
|
118
|
+
const hash = 'h1';
|
|
119
|
+
let seen: QueryStatus[];
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
dm = makeDataModule();
|
|
123
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
124
|
+
seen = [];
|
|
125
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('emits fetching on 0→1 and idle only on the last exit', () => {
|
|
129
|
+
dm.beginFetching(hash); // registration
|
|
130
|
+
dm.beginFetching(hash); // overlapping poll round
|
|
131
|
+
expect(seen).toEqual(['fetching']);
|
|
132
|
+
|
|
133
|
+
dm.endFetching(hash); // inner cycle finishes — must NOT emit idle
|
|
134
|
+
expect(seen).toEqual(['fetching']);
|
|
135
|
+
expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
|
|
136
|
+
|
|
137
|
+
dm.endFetching(hash);
|
|
138
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('unbalanced endFetching settles to idle without going negative', () => {
|
|
142
|
+
dm.endFetching(hash); // already idle → no-op notification
|
|
143
|
+
expect(seen).toEqual([]);
|
|
144
|
+
dm.beginFetching(hash);
|
|
145
|
+
dm.endFetching(hash);
|
|
146
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
147
|
+
expect((dm as any).fetchDepth.has(hash)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('DataModule pending stream-update flush', () => {
|
|
152
|
+
let dm: DataModule<any>;
|
|
153
|
+
const hash = 'h1';
|
|
154
|
+
let processed: any[];
|
|
155
|
+
|
|
156
|
+
const makeUpdate = (op: 'CREATE' | 'UPDATE' | 'DELETE') =>
|
|
157
|
+
({ queryHash: hash, op, localArray: [] }) as any;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
dm = makeDataModule();
|
|
162
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
163
|
+
processed = [];
|
|
164
|
+
(dm as any).processStreamUpdate = async (u: any) => {
|
|
165
|
+
processed.push(u);
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
vi.useRealTimers();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('flushPendingStreamUpdate processes the debounced update exactly once', async () => {
|
|
174
|
+
const update = makeUpdate('CREATE');
|
|
175
|
+
await dm.onStreamUpdate(update);
|
|
176
|
+
expect(processed).toEqual([]); // still debounced
|
|
177
|
+
|
|
178
|
+
await dm.flushPendingStreamUpdate(hash);
|
|
179
|
+
expect(processed).toEqual([update]);
|
|
180
|
+
expect((dm as any).debounceTimers.has(hash)).toBe(false);
|
|
181
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
182
|
+
|
|
183
|
+
// The cancelled timer must not process it a second time.
|
|
184
|
+
await vi.runAllTimersAsync();
|
|
185
|
+
expect(processed).toEqual([update]);
|
|
186
|
+
|
|
187
|
+
// Nothing pending → flush is a no-op.
|
|
188
|
+
await dm.flushPendingStreamUpdate(hash);
|
|
189
|
+
expect(processed).toEqual([update]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the trailing-edge timer clears the pending entry itself', async () => {
|
|
193
|
+
const update = makeUpdate('UPDATE');
|
|
194
|
+
await dm.onStreamUpdate(update);
|
|
195
|
+
await vi.runAllTimersAsync();
|
|
196
|
+
expect(processed).toEqual([update]);
|
|
197
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('a DELETE supersedes and drops the pending coalesced update', async () => {
|
|
201
|
+
const create = makeUpdate('CREATE');
|
|
202
|
+
const del = makeUpdate('DELETE');
|
|
203
|
+
await dm.onStreamUpdate(create);
|
|
204
|
+
await dm.onStreamUpdate(del); // immediate, drops the pending CREATE
|
|
205
|
+
expect(processed).toEqual([del]);
|
|
206
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
207
|
+
await vi.runAllTimersAsync();
|
|
208
|
+
expect(processed).toEqual([del]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('finalizeDeregister clears pending update and fetch depth', async () => {
|
|
212
|
+
(dm as any).cache = { unregisterQuery: () => {} };
|
|
213
|
+
await dm.onStreamUpdate(makeUpdate('CREATE'));
|
|
214
|
+
dm.beginFetching(hash);
|
|
215
|
+
dm.finalizeDeregister(hash);
|
|
216
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
217
|
+
expect((dm as any).debounceTimers.has(hash)).toBe(false);
|
|
218
|
+
expect((dm as any).fetchDepth.has(hash)).toBe(false);
|
|
219
|
+
await vi.runAllTimersAsync();
|
|
220
|
+
expect(processed).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('DataModule notifyQuerySynced', () => {
|
|
225
|
+
const hash = 'h1';
|
|
226
|
+
|
|
227
|
+
function makeDmWithLocal(rows: any[]): DataModule<any> {
|
|
228
|
+
const local: any = { query: async () => [rows] };
|
|
229
|
+
return new DataModule({} as any, local, { tables: [] } as any, makeLogger(), 100);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it('notifies once per registration lifetime even when records are unchanged and updateCount > 0', async () => {
|
|
233
|
+
const dm = makeDmWithLocal([]);
|
|
234
|
+
const state = makeQueryState(hash);
|
|
235
|
+
state.updateCount = 7; // persisted from a previous registration
|
|
236
|
+
(dm as any).activeQueries.set(hash, state);
|
|
237
|
+
|
|
238
|
+
let notified = 0;
|
|
239
|
+
dm.subscribe(hash, () => {
|
|
240
|
+
notified++;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await dm.notifyQuerySynced(hash); // empty result, unchanged — must still emit
|
|
244
|
+
expect(notified).toBe(1);
|
|
245
|
+
|
|
246
|
+
await dm.notifyQuerySynced(hash); // already notified this lifetime → silent
|
|
247
|
+
expect(notified).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
});
|