@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -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 +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DownQueue } from './queue-down';
|
|
3
|
+
import type { DownEvent } from './queue-down';
|
|
4
|
+
import type { LocalStore } from '../../../services/database/index';
|
|
5
|
+
|
|
6
|
+
const silentLogger = {
|
|
7
|
+
child: () => silentLogger,
|
|
8
|
+
debug: () => {},
|
|
9
|
+
info: () => {},
|
|
10
|
+
warn: () => {},
|
|
11
|
+
error: () => {},
|
|
12
|
+
} as any;
|
|
13
|
+
|
|
14
|
+
const register = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
|
|
15
|
+
|
|
16
|
+
const hashOf = (e: DownEvent) => e.payload.hash;
|
|
17
|
+
|
|
18
|
+
function makeQueue() {
|
|
19
|
+
return new DownQueue({} as LocalStore, silentLogger);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('DownQueue.next failure handling', () => {
|
|
23
|
+
it('re-heads a failing event so a transient failure keeps its order', async () => {
|
|
24
|
+
const q = makeQueue();
|
|
25
|
+
q.push(register('a'));
|
|
26
|
+
q.push(register('b'));
|
|
27
|
+
|
|
28
|
+
const seen: string[] = [];
|
|
29
|
+
await expect(
|
|
30
|
+
q.next(async (e) => {
|
|
31
|
+
seen.push(hashOf(e));
|
|
32
|
+
throw new Error('503');
|
|
33
|
+
})
|
|
34
|
+
).rejects.toThrow('503');
|
|
35
|
+
|
|
36
|
+
expect(seen).toEqual(['a']);
|
|
37
|
+
// 'a' is back at the head, ahead of 'b'.
|
|
38
|
+
await expect(q.next(async (e) => { seen.push(hashOf(e)); throw new Error('503'); })).rejects.toThrow();
|
|
39
|
+
expect(seen).toEqual(['a', 'a']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rotates a persistently failing event to the back so others can proceed', async () => {
|
|
43
|
+
const q = makeQueue();
|
|
44
|
+
q.push(register('poison'));
|
|
45
|
+
q.push(register('healthy'));
|
|
46
|
+
|
|
47
|
+
const seen: string[] = [];
|
|
48
|
+
const failPoison = async (e: DownEvent) => {
|
|
49
|
+
seen.push(hashOf(e));
|
|
50
|
+
if (hashOf(e) === 'poison') throw new Error('400 rejected');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Three attempts keep the head; the third rotates it behind 'healthy'.
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
await expect(q.next(failPoison)).rejects.toThrow();
|
|
56
|
+
}
|
|
57
|
+
expect(seen).toEqual(['poison', 'poison', 'poison']);
|
|
58
|
+
|
|
59
|
+
// 'healthy' is no longer starved.
|
|
60
|
+
await q.next(failPoison);
|
|
61
|
+
expect(seen).toEqual(['poison', 'poison', 'poison', 'healthy']);
|
|
62
|
+
expect(q.size).toBe(1); // only the poison event remains
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not rotate when nothing else is waiting', async () => {
|
|
66
|
+
const q = makeQueue();
|
|
67
|
+
q.push(register('only'));
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < 5; i++) {
|
|
70
|
+
await expect(
|
|
71
|
+
q.next(async () => {
|
|
72
|
+
throw new Error('boom');
|
|
73
|
+
})
|
|
74
|
+
).rejects.toThrow();
|
|
75
|
+
}
|
|
76
|
+
expect(q.size).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('clears the failure count once an event succeeds', async () => {
|
|
80
|
+
const q = makeQueue();
|
|
81
|
+
const event = register('a');
|
|
82
|
+
q.push(event);
|
|
83
|
+
q.push(register('b'));
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
q.next(async () => {
|
|
87
|
+
throw new Error('boom');
|
|
88
|
+
})
|
|
89
|
+
).rejects.toThrow();
|
|
90
|
+
// Succeeds on the retry, so its streak resets rather than carrying over.
|
|
91
|
+
await q.next(async () => {});
|
|
92
|
+
expect(q.size).toBe(1);
|
|
93
|
+
|
|
94
|
+
q.push(event);
|
|
95
|
+
// A fresh streak: it keeps the head again rather than rotating immediately.
|
|
96
|
+
await expect(
|
|
97
|
+
q.next(async () => {
|
|
98
|
+
throw new Error('boom');
|
|
99
|
+
})
|
|
100
|
+
).rejects.toThrow();
|
|
101
|
+
const drained: string[] = [];
|
|
102
|
+
await q.next(async (e) => {
|
|
103
|
+
drained.push(hashOf(e));
|
|
104
|
+
});
|
|
105
|
+
expect(drained).toEqual(['b']);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
|
|
110
|
+
it('hands out events for distinct hashes so they can run in parallel', () => {
|
|
111
|
+
const q = makeQueue();
|
|
112
|
+
q.push(register('a'));
|
|
113
|
+
q.push(register('b'));
|
|
114
|
+
q.push(register('c'));
|
|
115
|
+
const busy = new Set<string>();
|
|
116
|
+
|
|
117
|
+
const first = q.takeNext(busy)!;
|
|
118
|
+
busy.add(hashOf(first));
|
|
119
|
+
const second = q.takeNext(busy)!;
|
|
120
|
+
busy.add(hashOf(second));
|
|
121
|
+
|
|
122
|
+
expect([hashOf(first), hashOf(second)]).toEqual(['a', 'b']);
|
|
123
|
+
expect(q.size).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('SKIPS an event whose hash is busy without reordering it', () => {
|
|
127
|
+
// The whole correctness argument: a `cleanup` must never overtake the
|
|
128
|
+
// `register` for the same query. A busy hash's event keeps its place in the
|
|
129
|
+
// queue and is simply passed over.
|
|
130
|
+
const q = makeQueue();
|
|
131
|
+
q.push(register('a'));
|
|
132
|
+
q.push(register('a'));
|
|
133
|
+
q.push(register('b'));
|
|
134
|
+
|
|
135
|
+
const busy = new Set(['a']);
|
|
136
|
+
const taken = q.takeNext(busy)!;
|
|
137
|
+
expect(hashOf(taken)).toBe('b');
|
|
138
|
+
|
|
139
|
+
// Once 'a' frees up, its two events come back out in their original order.
|
|
140
|
+
const rest = [q.takeNext(new Set())!, q.takeNext(new Set())!];
|
|
141
|
+
expect(rest.map(hashOf)).toEqual(['a', 'a']);
|
|
142
|
+
expect(q.size).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns undefined when every remaining event is blocked', () => {
|
|
146
|
+
const q = makeQueue();
|
|
147
|
+
q.push(register('a'));
|
|
148
|
+
expect(q.takeNext(new Set(['a']))).toBeUndefined();
|
|
149
|
+
// Blocked, not consumed.
|
|
150
|
+
expect(q.size).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('DownQueue.run', () => {
|
|
155
|
+
it('returns the error instead of throwing, and re-heads the event', async () => {
|
|
156
|
+
// A concurrent drain has other work in flight when one event fails; a
|
|
157
|
+
// rejection here would take that work down with it.
|
|
158
|
+
const q = makeQueue();
|
|
159
|
+
const event = register('a');
|
|
160
|
+
q.push(event);
|
|
161
|
+
const taken = q.takeNext(new Set())!;
|
|
162
|
+
|
|
163
|
+
const boom = new Error('nope');
|
|
164
|
+
const err = await q.run(taken, async () => {
|
|
165
|
+
throw boom;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(err).toBe(boom);
|
|
169
|
+
expect(q.size).toBe(1);
|
|
170
|
+
expect(hashOf(q.takeNext(new Set())!)).toBe('a');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns undefined on success', async () => {
|
|
174
|
+
const q = makeQueue();
|
|
175
|
+
q.push(register('a'));
|
|
176
|
+
const taken = q.takeNext(new Set())!;
|
|
177
|
+
await expect(q.run(taken, async () => {})).resolves.toBeUndefined();
|
|
178
|
+
expect(q.size).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { LocalStore } from '../../../services/database/index';
|
|
2
|
+
import type {
|
|
3
|
+
SyncQueueEventSystem} from '../events/index';
|
|
2
4
|
import {
|
|
3
5
|
createSyncQueueEventSystem,
|
|
4
|
-
SyncQueueEventSystem,
|
|
5
6
|
SyncQueueEventTypes,
|
|
6
7
|
} from '../events/index';
|
|
7
|
-
import { Logger } from '../../../services/logger/index';
|
|
8
|
+
import type { Logger } from '../../../services/logger/index';
|
|
8
9
|
|
|
9
10
|
export type RegisterEvent = {
|
|
10
11
|
type: 'register';
|
|
@@ -36,17 +37,28 @@ export type CleanupEvent = {
|
|
|
36
37
|
|
|
37
38
|
export type DownEvent = RegisterEvent | SyncEvent | HeartbeatEvent | CleanupEvent;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* How many times a failing event keeps its place at the head before it is
|
|
42
|
+
* rotated to the back. A transient failure (the SSP still bootstrapping) clears
|
|
43
|
+
* well inside this, so ordering is preserved for the common case; a permanently
|
|
44
|
+
* rejected event (a permission the SSP refuses to lower) stops holding every
|
|
45
|
+
* other query hostage behind it.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_HEAD_RETRIES = 3;
|
|
48
|
+
|
|
39
49
|
export class DownQueue {
|
|
40
50
|
private queue: DownEvent[] = [];
|
|
41
51
|
private _events: SyncQueueEventSystem;
|
|
42
52
|
private logger: Logger;
|
|
53
|
+
/** Consecutive failures per queued event; cleared when it finally succeeds. */
|
|
54
|
+
private failures = new WeakMap<DownEvent, number>();
|
|
43
55
|
|
|
44
56
|
get events(): SyncQueueEventSystem {
|
|
45
57
|
return this._events;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
constructor(
|
|
49
|
-
private local:
|
|
61
|
+
private local: LocalStore,
|
|
50
62
|
logger: Logger
|
|
51
63
|
) {
|
|
52
64
|
this._events = createSyncQueueEventSystem();
|
|
@@ -71,19 +83,74 @@ export class DownQueue {
|
|
|
71
83
|
});
|
|
72
84
|
}
|
|
73
85
|
|
|
86
|
+
/** Drop all queued events (bucket switch: old-bucket register/sync work is
|
|
87
|
+
* re-derived after the switch; replaying it would target dead query rows). */
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.queue = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
async next(fn: (event: DownEvent) => Promise<void>): Promise<void> {
|
|
75
93
|
const event = this.queue.shift();
|
|
76
|
-
if (event)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
if (!event) return;
|
|
95
|
+
const error = await this.run(event, fn);
|
|
96
|
+
if (error !== undefined) throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The next event whose hash is NOT already being processed, or `undefined`
|
|
101
|
+
* when every remaining event is blocked on a busy hash.
|
|
102
|
+
*
|
|
103
|
+
* Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
|
|
104
|
+
* `register` for the same query — but the queue enforced it globally, so one
|
|
105
|
+
* registration RPC at a time was the ceiling for the whole client. An event
|
|
106
|
+
* for a busy hash keeps its place here (it is skipped, not reordered), so
|
|
107
|
+
* per-hash ordering is preserved exactly while independent hashes proceed
|
|
108
|
+
* concurrently.
|
|
109
|
+
*/
|
|
110
|
+
takeNext(busy: ReadonlySet<string>): DownEvent | undefined {
|
|
111
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
112
|
+
const event = this.queue[i]!;
|
|
113
|
+
if (busy.has(event.payload.hash)) continue;
|
|
114
|
+
this.queue.splice(i, 1);
|
|
115
|
+
return event;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Process one event, applying the re-head / rotate failure policy.
|
|
122
|
+
*
|
|
123
|
+
* NEVER rejects: it RETURNS the error instead (`undefined` on success). A
|
|
124
|
+
* concurrent drain has other work in flight when one event fails, and a
|
|
125
|
+
* rejection would either take that work down with it or have to be caught at
|
|
126
|
+
* every call site. `next` reinstates the throwing contract for the serial
|
|
127
|
+
* callers that still want it.
|
|
128
|
+
*/
|
|
129
|
+
async run(
|
|
130
|
+
event: DownEvent,
|
|
131
|
+
fn: (event: DownEvent) => Promise<void>
|
|
132
|
+
): Promise<unknown | undefined> {
|
|
133
|
+
try {
|
|
134
|
+
await fn(event);
|
|
135
|
+
this.failures.delete(event);
|
|
136
|
+
return undefined;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
139
|
+
this.failures.set(event, attempts);
|
|
140
|
+
// Re-head so a transient failure keeps its ordering, but give up the
|
|
141
|
+
// head once it looks permanent and there is other work waiting — one
|
|
142
|
+
// unregisterable query must not stall every other query's registration.
|
|
143
|
+
const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
|
|
144
|
+
if (starvingOthers) {
|
|
145
|
+
this.queue.push(event);
|
|
146
|
+
} else {
|
|
84
147
|
this.queue.unshift(event);
|
|
85
|
-
throw error;
|
|
86
148
|
}
|
|
149
|
+
this.logger.error(
|
|
150
|
+
{ error, event, attempts, rotated: starvingOthers, Category: 'sp00ky-client::DownQueue::next' },
|
|
151
|
+
'Failed to process query'
|
|
152
|
+
);
|
|
153
|
+
return error;
|
|
87
154
|
}
|
|
88
155
|
}
|
|
89
156
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { UpQueue } from './queue-up';
|
|
3
|
+
import { translateSurql } from '../../../services/database/surql-translate';
|
|
4
|
+
import { surql, classifySyncError } from '../../../utils/index';
|
|
5
|
+
|
|
6
|
+
function makeLogger(): any {
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
9
|
+
l.child = () => l;
|
|
10
|
+
return l;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ROW = {
|
|
14
|
+
id: '_00_pending_mutations:0001753875000000_0001_tabB',
|
|
15
|
+
mutationType: 'create',
|
|
16
|
+
recordId: 'game:g1',
|
|
17
|
+
data: { title: 'x' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Shared-tabs: followers commit outbox rows into the shared store and notify
|
|
21
|
+
// the leader by id. The notify may be REPLAYED (failover buffering), so the
|
|
22
|
+
// enqueue must be idempotent, and only the leader ever drains.
|
|
23
|
+
describe('UpQueue.enqueueFromDatabase', () => {
|
|
24
|
+
function makeQueue(rows: Record<string, unknown[]>) {
|
|
25
|
+
const query = vi.fn(async (_sql: string, vars?: Record<string, unknown>) => {
|
|
26
|
+
const ids = (vars?.mutation_ids as unknown[]) ?? [];
|
|
27
|
+
const id = String(ids[0] ?? '');
|
|
28
|
+
return [rows[id] ?? []];
|
|
29
|
+
});
|
|
30
|
+
const local: any = { query };
|
|
31
|
+
return { queue: new UpQueue(local, makeLogger()), query };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('loads exactly the forwarded row and enqueues it once', async () => {
|
|
35
|
+
const { queue } = makeQueue({ [ROW.id]: [ROW] });
|
|
36
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
37
|
+
expect(queue.size).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is idempotent: a replayed notify does not double-enqueue', async () => {
|
|
41
|
+
const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
|
|
42
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
43
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
44
|
+
expect(queue.size).toBe(1);
|
|
45
|
+
// The second call short-circuits before even touching the store.
|
|
46
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('a row that never committed is a silent no-op (the app promise already rejected)', async () => {
|
|
50
|
+
const { queue } = makeQueue({});
|
|
51
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
52
|
+
expect(queue.size).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The mock above replaces `local.query` wholesale, so on its own it proves
|
|
56
|
+
// nothing about the SQLite engine actually being able to RUN the statement.
|
|
57
|
+
// That gap shipped a bug: the emitted `SELECT * FROM $mutation_id` passed a
|
|
58
|
+
// single RecordId where the engine's `selectByIds` lowering expects an array,
|
|
59
|
+
// so it threw, the surrounding catch swallowed it at `error` level, and every
|
|
60
|
+
// follower mutation was silently never pushed. Drive the real translator.
|
|
61
|
+
it('discards a create with no payload instead of queueing a guaranteed failure', async () => {
|
|
62
|
+
// `processUpEvent` does `Object.keys(event.data)`, so this row can never be
|
|
63
|
+
// sent. Queued, it sat at the HEAD and blocked every later mutation for the
|
|
64
|
+
// whole app: `next()` re-queues on failure, so the backlog never drained.
|
|
65
|
+
const dropped: any[] = [];
|
|
66
|
+
const rows: Record<string, unknown[]> = {
|
|
67
|
+
[ROW.id]: [{ id: ROW.id, mutationType: 'create', recordId: 'comment:c1' }],
|
|
68
|
+
};
|
|
69
|
+
const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
|
|
70
|
+
if (sql.startsWith('DELETE')) return [[]];
|
|
71
|
+
const ids = (vars?.mutation_ids as unknown[]) ?? [];
|
|
72
|
+
return [rows[String(ids[0] ?? '')] ?? []];
|
|
73
|
+
});
|
|
74
|
+
const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
|
|
75
|
+
|
|
76
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
77
|
+
|
|
78
|
+
expect(queue.size).toBe(0);
|
|
79
|
+
expect(dropped).toHaveLength(1);
|
|
80
|
+
// And the row is deleted, so it cannot re-poison the next boot.
|
|
81
|
+
expect(query.mock.calls.some(([sql]) => String(sql).startsWith('DELETE'))).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('loadFromDatabase drains the replayable rows even when one is unsendable', async () => {
|
|
85
|
+
const dropped: any[] = [];
|
|
86
|
+
const query = vi.fn(async (sql: string) => {
|
|
87
|
+
if (sql.startsWith('DELETE')) return [[]];
|
|
88
|
+
return [
|
|
89
|
+
[
|
|
90
|
+
{ id: '_00_pending_mutations:a', mutationType: 'create', recordId: 'comment:c1' },
|
|
91
|
+
{
|
|
92
|
+
id: '_00_pending_mutations:b',
|
|
93
|
+
mutationType: 'update',
|
|
94
|
+
recordId: 'game:g1',
|
|
95
|
+
data: { title: 'x' },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
];
|
|
99
|
+
});
|
|
100
|
+
const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
|
|
101
|
+
|
|
102
|
+
await queue.loadFromDatabase();
|
|
103
|
+
|
|
104
|
+
// The good row still loads; the poison one is dropped and reported.
|
|
105
|
+
expect(queue.size).toBe(1);
|
|
106
|
+
expect(dropped).toHaveLength(1);
|
|
107
|
+
expect(dropped[0].mutationId).toBe('_00_pending_mutations:a');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('persists the payload for a create, so it can be replayed at all', () => {
|
|
111
|
+
// The create branch used to accept `dataVar` and ignore it, so the outbox
|
|
112
|
+
// row was the ONLY copy of a pending create and it carried no data.
|
|
113
|
+
const stmt = surql.createMutation('create', 'mid', 'id', 'data');
|
|
114
|
+
expect(stmt).toContain('data = $data');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('deletes the row using the STORED id, not its escaped display form', async () => {
|
|
118
|
+
// Outbox ids contain `_` and `-`, so the store returns them escaped:
|
|
119
|
+
// `_00_pending_mutations:⟨1785…_0005_c960…⟩`. Parsing that verbatim and
|
|
120
|
+
// re-encoding escapes the brackets AGAIN, so the DELETE targeted
|
|
121
|
+
// `⟨⟨…\⟩⟩` and matched nothing: a mutation replayed from the store could
|
|
122
|
+
// never have its row removed, even after a SUCCESSFUL push, so it was
|
|
123
|
+
// re-sent on every boot.
|
|
124
|
+
const storedId = '_00_pending_mutations:⟨1785540216679_0005_c960958c-d6ed⟩';
|
|
125
|
+
const deletes: any[] = [];
|
|
126
|
+
const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
|
|
127
|
+
if (sql.startsWith('DELETE')) {
|
|
128
|
+
deletes.push(vars?.mutation_id);
|
|
129
|
+
return [[]];
|
|
130
|
+
}
|
|
131
|
+
return [[{ id: storedId, mutationType: 'create', recordId: 'comment:c1' }]];
|
|
132
|
+
});
|
|
133
|
+
const queue = new UpQueue({ query } as any, makeLogger(), () => {});
|
|
134
|
+
|
|
135
|
+
await queue.enqueueFromDatabase(storedId);
|
|
136
|
+
|
|
137
|
+
expect(deletes).toHaveLength(1);
|
|
138
|
+
const rid: any = deletes[0];
|
|
139
|
+
expect(String(rid.table)).toBe('_00_pending_mutations');
|
|
140
|
+
expect(rid.id).toBe('1785540216679_0005_c960958c-d6ed');
|
|
141
|
+
expect(String(rid.id)).not.toContain('⟨');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('a push timeout classifies as network, so it retries instead of rolling back', () => {
|
|
145
|
+
const err = new Error('Mutation push timed out after 30000ms (create)');
|
|
146
|
+
expect(classifySyncError(err)).toBe('network');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('emits a statement the SQLite engine can actually translate', async () => {
|
|
150
|
+
const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
|
|
151
|
+
await queue.enqueueFromDatabase(ROW.id);
|
|
152
|
+
|
|
153
|
+
const [sql, vars] = query.mock.calls[0] as [string, Record<string, unknown>];
|
|
154
|
+
const translated = translateSurql(sql, vars);
|
|
155
|
+
const op: any = translated.ops[0];
|
|
156
|
+
|
|
157
|
+
expect(op.kind).toBe('selectByIds');
|
|
158
|
+
// The engine does `ids.length` then `ids.map(...)`: a non-array silently
|
|
159
|
+
// skips the empty-guard and then throws.
|
|
160
|
+
expect(Array.isArray(op.ids)).toBe(true);
|
|
161
|
+
expect(op.ids).toHaveLength(1);
|
|
162
|
+
expect(() => (op.ids as unknown[]).map((x) => x)).not.toThrow();
|
|
163
|
+
});
|
|
164
|
+
});
|