@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131
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 +56 -0
- package/dist/index.d.ts +1171 -372
- package/dist/index.js +5726 -1277
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1234 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -392
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { DataModule } from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the recurring-outbox API added to DataModule: runRecurring builds a
|
|
6
|
+
* single deterministic schedule row (idempotent, swallows a duplicate CREATE);
|
|
7
|
+
* pokeRecurring bumps next_run_at only when the schedule exists; cancelRecurring
|
|
8
|
+
* deletes it. The heavy create/update/delete pipeline is spied so these assert
|
|
9
|
+
* the orchestration (deterministic id, field-building, existence gating) without
|
|
10
|
+
* a real engine.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function makeLogger(): any {
|
|
14
|
+
const noop = () => {};
|
|
15
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
16
|
+
logger.child = () => logger;
|
|
17
|
+
return logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const schema = {
|
|
21
|
+
tables: [{ name: 'job', columns: {} }],
|
|
22
|
+
backends: {
|
|
23
|
+
gamesync: {
|
|
24
|
+
outboxTable: 'job',
|
|
25
|
+
routes: { '/syncGames': { args: { connection: { optional: false } } } },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CONN = 'connection:CONN_abc';
|
|
31
|
+
|
|
32
|
+
// localQueryResult drives the existence check ([] = absent, [row] = present).
|
|
33
|
+
function makeDm(localQueryResult: unknown[]) {
|
|
34
|
+
const local = { query: vi.fn().mockResolvedValue(localQueryResult) };
|
|
35
|
+
const dm = new DataModule({} as any, local as any, schema as any, makeLogger(), 100);
|
|
36
|
+
const create = vi.spyOn(dm, 'create').mockResolvedValue(undefined as any);
|
|
37
|
+
const update = vi.spyOn(dm, 'update').mockResolvedValue(undefined as any);
|
|
38
|
+
const del = vi.spyOn(dm, 'delete').mockResolvedValue(undefined as any);
|
|
39
|
+
return { dm, local, create, update, del };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('DataModule.runRecurring', () => {
|
|
43
|
+
it('creates a single deterministic schedule row with the recurring fields', async () => {
|
|
44
|
+
const { dm, create } = makeDm([]); // no existing row
|
|
45
|
+
await dm.runRecurring(
|
|
46
|
+
'gamesync' as any,
|
|
47
|
+
'/syncGames' as any,
|
|
48
|
+
{ connection: CONN } as any,
|
|
49
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
53
|
+
const [id, record] = create.mock.calls[0] as [string, any];
|
|
54
|
+
expect(id.startsWith('job:')).toBe(true);
|
|
55
|
+
expect(record.recurring).toBe(true);
|
|
56
|
+
expect(record.interval).toBe(300000);
|
|
57
|
+
expect(record.next_run_at).toBeInstanceOf(Date);
|
|
58
|
+
expect(record.assigned_to).toBe(CONN);
|
|
59
|
+
expect(record.path).toBe('/syncGames');
|
|
60
|
+
expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('is idempotent: does nothing when a schedule already exists', async () => {
|
|
64
|
+
const { dm, create } = makeDm([{ id: 'job:x' }]); // existing row
|
|
65
|
+
await dm.runRecurring(
|
|
66
|
+
'gamesync' as any,
|
|
67
|
+
'/syncGames' as any,
|
|
68
|
+
{ connection: CONN } as any,
|
|
69
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
70
|
+
);
|
|
71
|
+
expect(create).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('swallows a duplicate CREATE (row exists on server but not yet synced locally)', async () => {
|
|
75
|
+
const { dm, create } = makeDm([]); // local says absent
|
|
76
|
+
create.mockRejectedValueOnce(new Error('record already exists'));
|
|
77
|
+
await expect(
|
|
78
|
+
dm.runRecurring(
|
|
79
|
+
'gamesync' as any,
|
|
80
|
+
'/syncGames' as any,
|
|
81
|
+
{ connection: CONN } as any,
|
|
82
|
+
{ assignedTo: CONN, interval: 300000 }
|
|
83
|
+
)
|
|
84
|
+
).resolves.toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('uses a stable id per (assignedTo, path)', async () => {
|
|
88
|
+
const a = makeDm([]);
|
|
89
|
+
await a.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
90
|
+
const b = makeDm([]);
|
|
91
|
+
await b.dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
92
|
+
expect(a.create.mock.calls[0][0]).toBe(b.create.mock.calls[0][0]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('validates required options', async () => {
|
|
96
|
+
const { dm } = makeDm([]);
|
|
97
|
+
await expect(
|
|
98
|
+
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN } as any)
|
|
99
|
+
).rejects.toThrow(/interval/);
|
|
100
|
+
await expect(
|
|
101
|
+
dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { interval: 300000 } as any)
|
|
102
|
+
).rejects.toThrow(/assignedTo/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('DataModule.pokeRecurring', () => {
|
|
107
|
+
it('bumps next_run_at on the existing schedule row', async () => {
|
|
108
|
+
const { dm, update } = makeDm([{ id: 'job:x' }]);
|
|
109
|
+
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
110
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
111
|
+
const [table, id, data] = update.mock.calls[0] as [string, string, any];
|
|
112
|
+
expect(table).toBe('job');
|
|
113
|
+
expect(id.startsWith('job:')).toBe(true);
|
|
114
|
+
expect(data.next_run_at).toBeInstanceOf(Date);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('is a no-op when no schedule exists', async () => {
|
|
118
|
+
const { dm, update } = makeDm([]);
|
|
119
|
+
await dm.pokeRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
120
|
+
expect(update).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('DataModule.cancelRecurring', () => {
|
|
125
|
+
it('deletes the deterministic schedule row', async () => {
|
|
126
|
+
const { dm, del, create } = makeDm([]);
|
|
127
|
+
// Capture the id runRecurring would create, to prove cancel targets the same.
|
|
128
|
+
await dm.runRecurring('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, { assignedTo: CONN, interval: 300000 });
|
|
129
|
+
const createdId = create.mock.calls[0][0];
|
|
130
|
+
|
|
131
|
+
await dm.cancelRecurring('gamesync' as any, '/syncGames' as any, { assignedTo: CONN });
|
|
132
|
+
expect(del).toHaveBeenCalledTimes(1);
|
|
133
|
+
const [table, id] = del.mock.calls[0] as [string, string];
|
|
134
|
+
expect(table).toBe('job');
|
|
135
|
+
expect(id).toBe(createdId);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
import type { QueryState, QueryStatus } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for the per-query fetch status (idle/fetching) added to DataModule:
|
|
8
|
+
* setQueryStatus updates the query object and notifies both the DevTools
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function makeLogger(): any {
|
|
15
|
+
const noop = () => {};
|
|
16
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
17
|
+
logger.child = () => logger;
|
|
18
|
+
return logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeDataModule(): DataModule<any> {
|
|
22
|
+
return new DataModule(
|
|
23
|
+
{} as any, // cache — unused by status methods
|
|
24
|
+
{} as any, // local — unused by status methods
|
|
25
|
+
{ tables: [] } as any, // schema — unused by status methods
|
|
26
|
+
makeLogger(),
|
|
27
|
+
100
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeQueryState(hash: string): QueryState {
|
|
32
|
+
return {
|
|
33
|
+
config: {
|
|
34
|
+
id: new RecordId('_00_query', hash),
|
|
35
|
+
surql: 'SELECT * FROM user',
|
|
36
|
+
params: {},
|
|
37
|
+
localArray: [],
|
|
38
|
+
remoteArray: [],
|
|
39
|
+
ttl: '10m',
|
|
40
|
+
lastActiveAt: new Date(),
|
|
41
|
+
tableName: 'user',
|
|
42
|
+
},
|
|
43
|
+
records: [],
|
|
44
|
+
ttlTimer: null,
|
|
45
|
+
ttlDurationMs: 0,
|
|
46
|
+
updateCount: 0,
|
|
47
|
+
lastUpdatedAt: null,
|
|
48
|
+
materializationSamples: [],
|
|
49
|
+
lastIngestLatencyMs: null,
|
|
50
|
+
errorCount: 0,
|
|
51
|
+
status: 'idle',
|
|
52
|
+
phaseSamples: {},
|
|
53
|
+
phaseLast: {},
|
|
54
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('DataModule query fetch status', () => {
|
|
59
|
+
let dm: DataModule<any>;
|
|
60
|
+
const hash = 'h1';
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
dm = makeDataModule();
|
|
64
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('subscribeStatus with immediate reports the current status', () => {
|
|
68
|
+
const seen: QueryStatus[] = [];
|
|
69
|
+
dm.subscribeStatus(hash, (s) => seen.push(s), { immediate: true });
|
|
70
|
+
expect(seen).toEqual(['idle']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('setQueryStatus updates the query object and notifies subscribers + observer', () => {
|
|
74
|
+
const observed: Array<[string, QueryStatus]> = [];
|
|
75
|
+
dm.onQueryStatusChange = (h, s) => observed.push([h, s]);
|
|
76
|
+
|
|
77
|
+
const seen: QueryStatus[] = [];
|
|
78
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
79
|
+
|
|
80
|
+
dm.setQueryStatus(hash, 'fetching');
|
|
81
|
+
expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
|
|
82
|
+
expect(seen).toEqual(['fetching']);
|
|
83
|
+
expect(observed).toEqual([[hash, 'fetching']]);
|
|
84
|
+
|
|
85
|
+
dm.setQueryStatus(hash, 'idle');
|
|
86
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
87
|
+
expect(observed).toEqual([[hash, 'fetching'], [hash, 'idle']]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('setQueryStatus is a no-op when the status is unchanged', () => {
|
|
91
|
+
const seen: QueryStatus[] = [];
|
|
92
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
93
|
+
dm.setQueryStatus(hash, 'idle'); // already idle
|
|
94
|
+
expect(seen).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('setQueryStatus is a no-op for an unknown query', () => {
|
|
98
|
+
let called = false;
|
|
99
|
+
dm.onQueryStatusChange = () => {
|
|
100
|
+
called = true;
|
|
101
|
+
};
|
|
102
|
+
dm.setQueryStatus('does-not-exist', 'fetching');
|
|
103
|
+
expect(called).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('unsubscribe stops further status notifications', () => {
|
|
107
|
+
const seen: QueryStatus[] = [];
|
|
108
|
+
const unsub = dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
109
|
+
dm.setQueryStatus(hash, 'fetching');
|
|
110
|
+
unsub();
|
|
111
|
+
dm.setQueryStatus(hash, 'idle');
|
|
112
|
+
expect(seen).toEqual(['fetching']);
|
|
113
|
+
});
|
|
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
|
+
});
|