@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Logger } from '../../services/logger/index';
|
|
2
|
-
import { UpQueue, DownQueue, DownEvent, UpEvent, RollbackCallback } from './queue/index';
|
|
1
|
+
import type { Logger } from '../../services/logger/index';
|
|
2
|
+
import type { UpQueue, DownQueue, DownEvent, UpEvent, RollbackCallback } from './queue/index';
|
|
3
3
|
import { SyncQueueEventTypes } from './events/index';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -9,6 +9,8 @@ import { SyncQueueEventTypes } from './events/index';
|
|
|
9
9
|
export class SyncScheduler {
|
|
10
10
|
private isSyncingUp: boolean = false;
|
|
11
11
|
private isSyncingDown: boolean = false;
|
|
12
|
+
private paused: boolean = false;
|
|
13
|
+
private pauseWaiters: Array<() => void> = [];
|
|
12
14
|
|
|
13
15
|
constructor(
|
|
14
16
|
private upQueue: UpQueue,
|
|
@@ -16,7 +18,12 @@ export class SyncScheduler {
|
|
|
16
18
|
private onProcessUp: (event: UpEvent) => Promise<void>,
|
|
17
19
|
private onProcessDown: (event: DownEvent) => Promise<void>,
|
|
18
20
|
private logger: Logger,
|
|
19
|
-
private onRollback?: RollbackCallback
|
|
21
|
+
private onRollback?: RollbackCallback,
|
|
22
|
+
// Reports the outcome of each drained sync round (one syncUp/syncDown pass
|
|
23
|
+
// that actually processed ≥1 item): `ok=true` on a clean drain, `ok=false`
|
|
24
|
+
// with the error when the round halted on a failure. Drives the consumer's
|
|
25
|
+
// sync-health tracking; empty/no-op rounds report nothing.
|
|
26
|
+
private onSyncOutcome?: (ok: boolean, error?: unknown) => void
|
|
20
27
|
) {}
|
|
21
28
|
|
|
22
29
|
async init() {
|
|
@@ -44,18 +51,60 @@ export class SyncScheduler {
|
|
|
44
51
|
this.downQueue.push(event);
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Suspend syncing for a local-bucket switch. Refuses new rounds and resolves
|
|
56
|
+
* once any in-flight round has finished — the pause point is BETWEEN queue
|
|
57
|
+
* items, never between an item's remote push and its outbox-row delete, so a
|
|
58
|
+
* processed mutation's `DELETE _00_pending_mutations` always lands in the
|
|
59
|
+
* store it was read from.
|
|
60
|
+
*/
|
|
61
|
+
pause(): Promise<void> {
|
|
62
|
+
this.paused = true;
|
|
63
|
+
if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
|
|
64
|
+
return new Promise<void>((resolve) => this.pauseWaiters.push(resolve));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
resume(): void {
|
|
68
|
+
this.paused = false;
|
|
69
|
+
void this.syncUp();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private maybeResolvePause() {
|
|
73
|
+
if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
|
|
74
|
+
const waiters = this.pauseWaiters;
|
|
75
|
+
this.pauseWaiters = [];
|
|
76
|
+
for (const resolve of waiters) resolve();
|
|
77
|
+
}
|
|
78
|
+
|
|
47
79
|
/**
|
|
48
80
|
* Process upload queue
|
|
49
81
|
*/
|
|
50
82
|
async syncUp() {
|
|
51
|
-
if (this.isSyncingUp) return;
|
|
83
|
+
if (this.isSyncingUp || this.paused) return;
|
|
52
84
|
this.isSyncingUp = true;
|
|
85
|
+
let processedAny = false;
|
|
53
86
|
try {
|
|
54
|
-
while (this.upQueue.size > 0) {
|
|
87
|
+
while (this.upQueue.size > 0 && !this.paused) {
|
|
55
88
|
await this.upQueue.next(this.onProcessUp, this.onRollback);
|
|
89
|
+
processedAny = true;
|
|
56
90
|
}
|
|
91
|
+
if (processedAny) this.onSyncOutcome?.(true);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.onSyncOutcome?.(false, error);
|
|
94
|
+
// syncUp runs fire-and-forget — it's wired to the MutationEnqueued event
|
|
95
|
+
// (broadcast synchronously, return value dropped) and is also kicked off
|
|
96
|
+
// via `void this.syncDown()` below. A rejection escaping here therefore
|
|
97
|
+
// surfaces as an *unhandled promise rejection* in the console rather than
|
|
98
|
+
// anything a caller can catch. UpQueue.next already logs the failing item
|
|
99
|
+
// (and re-queues it for retry on the next trigger), so swallow here to
|
|
100
|
+
// keep the failure contained instead of leaking it globally.
|
|
101
|
+
this.logger.debug(
|
|
102
|
+
{ error, Category: 'sp00ky-client::SyncScheduler::syncUp' },
|
|
103
|
+
'syncUp halted on a queue error; item re-queued, will retry on next trigger'
|
|
104
|
+
);
|
|
57
105
|
} finally {
|
|
58
106
|
this.isSyncingUp = false;
|
|
107
|
+
this.maybeResolvePause();
|
|
59
108
|
void this.syncDown();
|
|
60
109
|
}
|
|
61
110
|
}
|
|
@@ -64,17 +113,34 @@ export class SyncScheduler {
|
|
|
64
113
|
* Process download queue
|
|
65
114
|
*/
|
|
66
115
|
async syncDown() {
|
|
67
|
-
if (this.isSyncingDown) return;
|
|
116
|
+
if (this.isSyncingDown || this.paused) return;
|
|
68
117
|
if (this.upQueue.size > 0) return;
|
|
69
118
|
|
|
70
119
|
this.isSyncingDown = true;
|
|
120
|
+
let processedAny = false;
|
|
71
121
|
try {
|
|
72
|
-
while (this.downQueue.size > 0) {
|
|
122
|
+
while (this.downQueue.size > 0 && !this.paused) {
|
|
73
123
|
if (this.upQueue.size > 0) break;
|
|
74
124
|
await this.downQueue.next(this.onProcessDown);
|
|
125
|
+
processedAny = true;
|
|
75
126
|
}
|
|
127
|
+
if (processedAny) this.onSyncOutcome?.(true);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.onSyncOutcome?.(false, error);
|
|
130
|
+
// Same fire-and-forget story as syncUp: this is the QueryItemEnqueued
|
|
131
|
+
// subscriber (and is also called via `void this.syncDown()`), so a thrown
|
|
132
|
+
// error here becomes an unhandled rejection. The canonical case is a
|
|
133
|
+
// transient remote 500 on `fn::query::register` — DownQueue.next logs it
|
|
134
|
+
// and re-queues the event at the head; we just stop draining this pass and
|
|
135
|
+
// let the next enqueue retry, without spamming the console with an
|
|
136
|
+
// "Uncaught (in promise) ... 500 Internal Server Error".
|
|
137
|
+
this.logger.debug(
|
|
138
|
+
{ error, Category: 'sp00ky-client::SyncScheduler::syncDown' },
|
|
139
|
+
'syncDown halted on a queue error; item re-queued, will retry on next trigger'
|
|
140
|
+
);
|
|
76
141
|
} finally {
|
|
77
142
|
this.isSyncingDown = false;
|
|
143
|
+
this.maybeResolvePause();
|
|
78
144
|
}
|
|
79
145
|
}
|
|
80
146
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Sp00kySync } from './sync';
|
|
3
|
+
|
|
4
|
+
// The idle list-ref poll is the only health signal that runs on a quiet page.
|
|
5
|
+
// These tests exercise `pollListRefForActiveQueries` -> `recordSyncOutcome` in
|
|
6
|
+
// isolation: a run of network-failed cycles degrades, and a single clean cycle
|
|
7
|
+
// recovers WITHOUT any mutation (the reported bug). Construction is cheap —
|
|
8
|
+
// Sp00kySync's constructor only stores refs and calls logger.child; no timers or
|
|
9
|
+
// I/O start until init(), which we never call.
|
|
10
|
+
|
|
11
|
+
const CONNECTION_UNAVAILABLE =
|
|
12
|
+
'You must be connected to a SurrealDB instance before performing this operation';
|
|
13
|
+
|
|
14
|
+
function makeSync(hashes: string[]) {
|
|
15
|
+
const logger: any = {
|
|
16
|
+
child: () => logger,
|
|
17
|
+
debug: () => {},
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
trace: () => {},
|
|
22
|
+
};
|
|
23
|
+
const remote: any = { query: vi.fn().mockResolvedValue([true]) };
|
|
24
|
+
const dataModule: any = { getActiveQueryHashes: () => hashes };
|
|
25
|
+
|
|
26
|
+
const sync = new Sp00kySync(
|
|
27
|
+
{} as any,
|
|
28
|
+
remote,
|
|
29
|
+
{} as any,
|
|
30
|
+
dataModule,
|
|
31
|
+
{} as any,
|
|
32
|
+
logger,
|
|
33
|
+
{ degradeAfterConsecutiveFailures: 3 }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// `refetchListRefForQuery` is what the poll calls per active hash; stub it so
|
|
37
|
+
// the test controls per-cycle reachability. `poll` invokes the private method.
|
|
38
|
+
const refetch = vi.fn();
|
|
39
|
+
(sync as any).refetchListRefForQuery = refetch;
|
|
40
|
+
const poll = () => (sync as any).pollListRefForActiveQueries() as Promise<boolean>;
|
|
41
|
+
|
|
42
|
+
return { sync, remote, refetch, poll };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('sync health via idle poll', () => {
|
|
46
|
+
beforeEach(() => vi.clearAllMocks());
|
|
47
|
+
|
|
48
|
+
it('degrades after N consecutive network-failed poll cycles', async () => {
|
|
49
|
+
const { sync, refetch, poll } = makeSync(['h1']);
|
|
50
|
+
refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
51
|
+
|
|
52
|
+
await poll();
|
|
53
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
54
|
+
await poll();
|
|
55
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
56
|
+
await poll();
|
|
57
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
58
|
+
expect(sync.syncHealth.kind).toBe('network');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('recovers on the next clean poll cycle — no mutation needed', async () => {
|
|
62
|
+
const { sync, refetch, poll } = makeSync(['h1']);
|
|
63
|
+
refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
64
|
+
await poll();
|
|
65
|
+
await poll();
|
|
66
|
+
await poll();
|
|
67
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
68
|
+
|
|
69
|
+
// Connectivity returns; a plain idle poll (no user action) clears it.
|
|
70
|
+
refetch.mockResolvedValue(true);
|
|
71
|
+
await poll();
|
|
72
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not degrade on application errors (server was reached)', async () => {
|
|
76
|
+
const { sync, refetch, poll } = makeSync(['h1']);
|
|
77
|
+
refetch.mockRejectedValue(new Error('Permission denied'));
|
|
78
|
+
await poll();
|
|
79
|
+
await poll();
|
|
80
|
+
await poll();
|
|
81
|
+
await poll();
|
|
82
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('counts a mixed cycle (one reachable hash) as reached', async () => {
|
|
86
|
+
const { sync, refetch, poll } = makeSync(['h1', 'h2']);
|
|
87
|
+
// First degrade via all-network cycles.
|
|
88
|
+
refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
89
|
+
await poll();
|
|
90
|
+
await poll();
|
|
91
|
+
await poll();
|
|
92
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
93
|
+
|
|
94
|
+
// Now one hash succeeds, the other still network-fails → reached → healthy.
|
|
95
|
+
refetch.mockReset();
|
|
96
|
+
refetch
|
|
97
|
+
.mockResolvedValueOnce(true)
|
|
98
|
+
.mockRejectedValueOnce(new Error(CONNECTION_UNAVAILABLE));
|
|
99
|
+
await poll();
|
|
100
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('probes RETURN true when there are no active queries', async () => {
|
|
104
|
+
const { sync, remote, poll } = makeSync([]);
|
|
105
|
+
remote.query.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
106
|
+
await poll();
|
|
107
|
+
await poll();
|
|
108
|
+
await poll();
|
|
109
|
+
expect(remote.query).toHaveBeenCalledWith('RETURN true');
|
|
110
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
111
|
+
|
|
112
|
+
remote.query.mockResolvedValue([true]);
|
|
113
|
+
await poll();
|
|
114
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('leaves everConnected false through a cold-start failure run', async () => {
|
|
118
|
+
const { sync, refetch, poll } = makeSync(['h1']);
|
|
119
|
+
expect(sync.syncHealth.everConnected).toBe(false);
|
|
120
|
+
|
|
121
|
+
// Server never reached: 3 failed cycles degrade, but this is the initial
|
|
122
|
+
// "connecting" phase, not a lost connection — everConnected stays false.
|
|
123
|
+
refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
124
|
+
await poll();
|
|
125
|
+
await poll();
|
|
126
|
+
await poll();
|
|
127
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
128
|
+
expect(sync.syncHealth.everConnected).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('latches everConnected on the first success and keeps it through a later degrade', async () => {
|
|
132
|
+
const { sync, refetch, poll } = makeSync(['h1']);
|
|
133
|
+
|
|
134
|
+
// First successful round reaches the server: connecting phase is over.
|
|
135
|
+
refetch.mockResolvedValue(true);
|
|
136
|
+
await poll();
|
|
137
|
+
expect(sync.syncHealth.status).toBe('healthy');
|
|
138
|
+
expect(sync.syncHealth.everConnected).toBe(true);
|
|
139
|
+
|
|
140
|
+
// Connection later drops: degraded now reflects a REAL lost connection.
|
|
141
|
+
refetch.mockReset();
|
|
142
|
+
refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
|
|
143
|
+
await poll();
|
|
144
|
+
await poll();
|
|
145
|
+
await poll();
|
|
146
|
+
expect(sync.syncHealth.status).toBe('degraded');
|
|
147
|
+
expect(sync.syncHealth.everConnected).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
import { buildSubqueryListRefSelect } from './utils';
|
|
5
|
+
import { encodeRecordId } from '../../utils/index';
|
|
6
|
+
|
|
7
|
+
// Guards the CLIENT half of `.related()` (author/comments) delivery: given
|
|
8
|
+
// subquery-child edges on the server (`_00_list_ref…` rows with
|
|
9
|
+
// `parent IS NOT NONE`), `syncSubqueryChildren` MUST pull the child bodies
|
|
10
|
+
// through the sync engine so a later re-materialization attaches them to the
|
|
11
|
+
// parent. If this stops forwarding the child ids, related fields come back
|
|
12
|
+
// empty (threads render with an "Anonymous" author, comments vanish) even
|
|
13
|
+
// though the server wrote the edges correctly.
|
|
14
|
+
|
|
15
|
+
function makeSync() {
|
|
16
|
+
const logger: any = {
|
|
17
|
+
child: () => logger,
|
|
18
|
+
debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
|
|
19
|
+
};
|
|
20
|
+
const remote: any = { query: vi.fn() };
|
|
21
|
+
const queryId = new RecordId('_00_query', 'h1');
|
|
22
|
+
const queryState: any = { config: { id: queryId, subqueryRemoteArray: [] } };
|
|
23
|
+
const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
|
|
24
|
+
|
|
25
|
+
const sync = new Sp00kySync(
|
|
26
|
+
{} as any, remote, {} as any, dataModule, {} as any, logger,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// syncEngine is constructed internally; replace it with a spy.
|
|
30
|
+
const syncRecords = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
(sync as any).syncEngine = { syncRecords };
|
|
32
|
+
// Pin the resolved per-user list_ref table (auth routing is out of scope here).
|
|
33
|
+
(sync as any).listRefTable = () => '_00_list_ref_user_x';
|
|
34
|
+
|
|
35
|
+
const run = (hash: string) => (sync as any).syncSubqueryChildren(hash) as Promise<void>;
|
|
36
|
+
return { remote, syncRecords, queryState, queryId, run };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('syncSubqueryChildren — related-child delivery', () => {
|
|
40
|
+
beforeEach(() => vi.clearAllMocks());
|
|
41
|
+
|
|
42
|
+
it('reads the subquery edges (parent IS NOT NONE) scoped to the query id', async () => {
|
|
43
|
+
const { remote, run, queryId } = makeSync();
|
|
44
|
+
remote.query.mockResolvedValue([[]]);
|
|
45
|
+
|
|
46
|
+
await run('h1');
|
|
47
|
+
|
|
48
|
+
expect(remote.query).toHaveBeenCalledWith(
|
|
49
|
+
buildSubqueryListRefSelect('_00_list_ref_user_x'),
|
|
50
|
+
{ in: queryId }
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('forwards each subquery child id to the sync engine so its body is cached', async () => {
|
|
55
|
+
const { remote, syncRecords, run } = makeSync();
|
|
56
|
+
// Server has one author child edge for this query.
|
|
57
|
+
remote.query.mockResolvedValue([[
|
|
58
|
+
{ out: new RecordId('user', 'u'), version: 1 },
|
|
59
|
+
]]);
|
|
60
|
+
|
|
61
|
+
await run('h1');
|
|
62
|
+
|
|
63
|
+
expect(syncRecords).toHaveBeenCalledTimes(1);
|
|
64
|
+
const arg = syncRecords.mock.calls[0][0];
|
|
65
|
+
expect(arg.added).toHaveLength(1);
|
|
66
|
+
expect(encodeRecordId(arg.added[0].id)).toBe('user:u');
|
|
67
|
+
expect(arg.added[0].version).toBe(1);
|
|
68
|
+
expect(arg.removed).toEqual([]); // shared child bodies are never deleted here
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('is idempotent: unchanged edges do not refetch bodies', async () => {
|
|
72
|
+
const { remote, syncRecords, run, queryState } = makeSync();
|
|
73
|
+
queryState.config.subqueryRemoteArray = [['user:u', 1]];
|
|
74
|
+
remote.query.mockResolvedValue([[
|
|
75
|
+
{ out: new RecordId('user', 'u'), version: 1 },
|
|
76
|
+
]]);
|
|
77
|
+
|
|
78
|
+
await run('h1');
|
|
79
|
+
|
|
80
|
+
expect(syncRecords).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|