@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381
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 +91 -0
- package/README.md +59 -16
- package/dist/index.d.ts +2238 -144
- package/dist/index.js +18149 -2475
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +401 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +2082 -39
- package/dist/worker-edges.js +498 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +42 -8
- package/skills/sp00ky-core/references/config.md +8 -2
- package/src/boot/auth-flip.saga.test.ts +174 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +191 -0
- package/src/boot/bucket-switch.saga.ts +151 -0
- package/src/boot/preload.saga.test.ts +96 -0
- package/src/boot/preload.saga.ts +45 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +59 -0
- package/src/client/router.ts +104 -0
- package/src/client/runtime.test.ts +319 -0
- package/src/client/runtime.ts +307 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +437 -0
- package/src/client/sp00ky-client.test.ts +428 -0
- package/src/client/sp00ky-client.ts +737 -0
- package/src/events/index.ts +3 -0
- package/src/index.ts +86 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +94 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +171 -0
- package/src/kernel/events.ts +60 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/events/index.ts +7 -1
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +209 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +538 -14
- package/src/modules/channel/channel.test.ts +752 -0
- package/src/modules/channel/index.ts +1153 -0
- package/src/modules/channel/webrtc.ts +472 -0
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +546 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +821 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -93
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +361 -0
- package/src/mutation/push.saga.ts +245 -0
- package/src/mutation/rows.test.ts +278 -0
- package/src/mutation/rows.ts +515 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +436 -0
- package/src/mutation/write.saga.ts +384 -0
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +531 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +200 -0
- package/src/query/materialize.saga.ts +102 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +514 -0
- package/src/query/membership.saga.ts +339 -0
- package/src/query/membership.test.ts +151 -0
- package/src/query/membership.ts +234 -0
- package/src/query/register.saga.test.ts +369 -0
- package/src/query/register.saga.ts +293 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/session-guard.integration.test.ts +304 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +174 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- 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 +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +151 -56
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +54 -9
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +503 -59
- 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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +283 -4
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +187 -6
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/persistence/surrealdb.ts +2 -2
- package/src/services/stream-processor/index.ts +801 -63
- 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 +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/wasm-types.ts +57 -1
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -394
- package/src/state/client-state.ts +231 -0
- package/src/state/lifecycle.test.ts +184 -0
- package/src/state/lifecycle.ts +127 -0
- package/src/state/reducers.test.ts +337 -0
- package/src/state/reducers.ts +571 -0
- package/src/state/selectors.test.ts +203 -0
- package/src/state/selectors.ts +264 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +229 -0
- package/src/sync/live.saga.ts +116 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +694 -7
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +57 -0
- package/src/utils/error-classification.test.ts +116 -0
- package/src/utils/error-classification.ts +74 -0
- package/src/utils/index.ts +83 -6
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +63 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +22 -11
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -882
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -78
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -90
- package/src/modules/sync/queue/queue-up.ts +0 -224
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -422
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -169
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import { Socket } from 'node:net';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
8
|
+
import { liveEndpoint } from '../live/index';
|
|
9
|
+
import { defaultEnv } from '../query/env';
|
|
10
|
+
import { RemoteDatabaseService } from '../services/database/remote';
|
|
11
|
+
import { buildState } from '../testing/build';
|
|
12
|
+
import { runPure } from '../testing/run-pure';
|
|
13
|
+
import type { ClientState } from '../state/client-state';
|
|
14
|
+
import type { OutEvent } from '../kernel/events';
|
|
15
|
+
import type { StatementResult } from '../kernel/effects';
|
|
16
|
+
import { mintMutationId } from './mutation-id';
|
|
17
|
+
import { drain, loadOutbox } from './push.saga';
|
|
18
|
+
import { BATCH_SKIPPED } from './rows';
|
|
19
|
+
import { write } from './write.saga';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Integration: a queued write that SurrealDB refuses because sp00ky's OWN
|
|
23
|
+
* ingest post failed (scheduler/SSP down, slow past the event TIMEOUT, or
|
|
24
|
+
* answering 5xx) must stay in the outbox and land once `/ingest` is back. It
|
|
25
|
+
* used to read as an application error, so the client rolled the write back
|
|
26
|
+
* and discarded it: silent data loss on every scheduler outage.
|
|
27
|
+
*
|
|
28
|
+
* Runs the real `write` and `drain` sagas, the client's real remote adapter,
|
|
29
|
+
* and the `http::post` line `spky migrate` generates (read from the Rust
|
|
30
|
+
* source, so the two cannot drift) against a real SurrealDB, with an HTTP sink
|
|
31
|
+
* in this process playing the scheduler. Boot one first, e.g.:
|
|
32
|
+
* docker run -d --name spooky-surreal-it -p 18011:8000 \
|
|
33
|
+
* surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
|
|
34
|
+
*
|
|
35
|
+
* Override the endpoint with SURREAL_IT_ENDPOINT and the host SurrealDB reaches
|
|
36
|
+
* this process at with SURREAL_IT_SINK_HOST (default `host.docker.internal`;
|
|
37
|
+
* `127.0.0.1` for a SurrealDB running on the host). Skips if unreachable.
|
|
38
|
+
*/
|
|
39
|
+
const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
|
|
40
|
+
const sinkHost = process.env.SURREAL_IT_SINK_HOST ?? 'host.docker.internal';
|
|
41
|
+
const NS = 'it';
|
|
42
|
+
/** Short so the hang case stays quick; the shipped value is 10s. */
|
|
43
|
+
const EVENT_TIMEOUT_SECS = 2;
|
|
44
|
+
|
|
45
|
+
const cliSource = (file: string) =>
|
|
46
|
+
readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
|
|
47
|
+
|
|
48
|
+
/** The `/ingest` post every generated event makes (`sp00ky.rs::ingest_post`). */
|
|
49
|
+
function ingestPost(): string {
|
|
50
|
+
const src = cliSource('sp00ky.rs');
|
|
51
|
+
const m = /fn ingest_post\(\) -> String \{\s*format!\(\s*"(.+?)\\n",/s.exec(src);
|
|
52
|
+
if (!m) throw new Error('ingest_post format string not found');
|
|
53
|
+
if (!src.includes('"DEFINE EVENT OVERWRITE _00_{}_mutation ON TABLE {}\\n"')) {
|
|
54
|
+
throw new Error('the mutation event is no longer named _00_<table>_mutation');
|
|
55
|
+
}
|
|
56
|
+
return m[1]
|
|
57
|
+
.replaceAll('\\"', '"')
|
|
58
|
+
.replaceAll('{{', '{')
|
|
59
|
+
.replaceAll('}}', '}')
|
|
60
|
+
.replace('{}', String(EVENT_TIMEOUT_SECS));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `flap` answers the next post 503, then switches itself back to `ok`. */
|
|
64
|
+
type SinkMode = 'ok' | '500' | '503' | 'hang' | 'flap';
|
|
65
|
+
|
|
66
|
+
/** Plays the scheduler's `/ingest` (and an app's own third-party endpoint). */
|
|
67
|
+
function startSink(): Promise<{ server: Server; port: number; mode: { current: SinkMode }; hits: string[] }> {
|
|
68
|
+
const mode = { current: 'ok' as SinkMode };
|
|
69
|
+
const hits: string[] = [];
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
req.resume();
|
|
72
|
+
hits.push(`${req.method} ${req.url} ${mode.current}`);
|
|
73
|
+
if (mode.current === 'hang') return; // never answers; SurrealDB's TIMEOUT fires
|
|
74
|
+
if (mode.current === 'flap') {
|
|
75
|
+
mode.current = 'ok';
|
|
76
|
+
res.writeHead(503);
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (mode.current === 'ok') {
|
|
81
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
82
|
+
res.end('{}');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(Number(mode.current));
|
|
86
|
+
res.end();
|
|
87
|
+
});
|
|
88
|
+
return new Promise((resolve) =>
|
|
89
|
+
server.listen(0, '127.0.0.1', () => {
|
|
90
|
+
const address = server.address();
|
|
91
|
+
resolve({ server, port: typeof address === 'object' && address ? address.port : 0, mode, hits });
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A port nothing listens on: a refused connection. */
|
|
97
|
+
async function closedPort(): Promise<number> {
|
|
98
|
+
const server = createServer();
|
|
99
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
100
|
+
const address = server.address();
|
|
101
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
102
|
+
return typeof address === 'object' && address ? address.port : 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const silent: any = { debug() {}, info() {}, warn() {}, error() {}, trace() {} };
|
|
106
|
+
silent.child = () => silent;
|
|
107
|
+
|
|
108
|
+
const env = defaultEnv({
|
|
109
|
+
tables: [
|
|
110
|
+
{ name: 'item', columns: { title: { type: 'string', optional: true } } },
|
|
111
|
+
{ name: 'lead', columns: { title: { type: 'string', optional: true } } },
|
|
112
|
+
],
|
|
113
|
+
} as any);
|
|
114
|
+
|
|
115
|
+
describe('a write refused by a failed ingest post is retried, not rolled back (real SurrealDB)', () => {
|
|
116
|
+
let reachable = false;
|
|
117
|
+
let local: Surreal;
|
|
118
|
+
let remote: RemoteDatabaseService;
|
|
119
|
+
let sink: Awaited<ReturnType<typeof startSink>>;
|
|
120
|
+
let sinkUrl = '';
|
|
121
|
+
let database = '';
|
|
122
|
+
/** Every per-statement error the remote answered, in order. */
|
|
123
|
+
const statementErrors: string[] = [];
|
|
124
|
+
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
const url = new URL(endpoint);
|
|
127
|
+
reachable = await new Promise<boolean>((resolve) => {
|
|
128
|
+
const socket = new Socket();
|
|
129
|
+
const done = (ok: boolean) => {
|
|
130
|
+
socket.destroy();
|
|
131
|
+
resolve(ok);
|
|
132
|
+
};
|
|
133
|
+
socket.setTimeout(1_000, () => done(false));
|
|
134
|
+
socket.once('error', () => done(false));
|
|
135
|
+
socket.connect(Number(url.port || 80), url.hostname, () => done(true));
|
|
136
|
+
});
|
|
137
|
+
if (!reachable) return;
|
|
138
|
+
|
|
139
|
+
sink = await startSink();
|
|
140
|
+
sinkUrl = `http://${sinkHost}:${sink.port}`;
|
|
141
|
+
|
|
142
|
+
// The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
|
|
143
|
+
// and Node's fetch has no `file:` scheme: serve that one read from disk.
|
|
144
|
+
const realFetch = globalThis.fetch;
|
|
145
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
146
|
+
const target = input instanceof URL ? input : new URL(String(input));
|
|
147
|
+
if (target.protocol === 'file:') return new Response(await readFile(fileURLToPath(target)));
|
|
148
|
+
return realFetch(input as RequestInfo, init);
|
|
149
|
+
});
|
|
150
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
151
|
+
local = new Surreal({ engines: createWasmEngines() });
|
|
152
|
+
await local.connect('mem://');
|
|
153
|
+
await local.use({ namespace: 'n', database: 'd' });
|
|
154
|
+
await local.query('DEFINE TABLE item SCHEMALESS; DEFINE TABLE lead SCHEMALESS; DEFINE TABLE _00_pending_mutations SCHEMALESS;');
|
|
155
|
+
|
|
156
|
+
database = `ingest_outage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
remote = new RemoteDatabaseService({ endpoint, namespace: NS, database } as any, silent);
|
|
158
|
+
await remote.connect();
|
|
159
|
+
await remote.signin({ username: 'root', password: 'root' });
|
|
160
|
+
await remote.query(`
|
|
161
|
+
DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${sinkUrl}' PERMISSIONS NONE;
|
|
162
|
+
DEFINE PARAM OVERWRITE $sp00ky_secret VALUE 'it-secret' PERMISSIONS NONE;
|
|
163
|
+
DEFINE TABLE item SCHEMALESS;
|
|
164
|
+
DEFINE EVENT OVERWRITE _00_item_mutation ON TABLE item
|
|
165
|
+
WHEN $before != $after AND $event != "DELETE"
|
|
166
|
+
THEN {
|
|
167
|
+
LET $payload = { table: 'item', op: $event, id: <string>$after.id, record: $after, hash: "" };
|
|
168
|
+
${ingestPost()}
|
|
169
|
+
};
|
|
170
|
+
DEFINE TABLE lead SCHEMALESS;
|
|
171
|
+
`);
|
|
172
|
+
}, 60_000);
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
if (!reachable) return;
|
|
176
|
+
sink.mode.current = 'ok';
|
|
177
|
+
await remote?.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
|
|
178
|
+
await remote?.close().catch(() => undefined);
|
|
179
|
+
await local?.close().catch(() => undefined);
|
|
180
|
+
sink?.server.closeAllConnections();
|
|
181
|
+
await new Promise<void>((resolve) => sink?.server.close(() => resolve()));
|
|
182
|
+
vi.unstubAllGlobals();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* With `loseAnswer` the push reaches the server and commits, then the client
|
|
187
|
+
* sees the push timeout instead of the answer (a slow server, a socket that
|
|
188
|
+
* dropped on the way back).
|
|
189
|
+
*/
|
|
190
|
+
const run = (saga: Generator<any, any, any>, state: ClientState, opts: { loseAnswer?: boolean } = {}) =>
|
|
191
|
+
runPure(saga, {
|
|
192
|
+
state,
|
|
193
|
+
now: Date.now(),
|
|
194
|
+
handlers: {
|
|
195
|
+
'local.query': (e: any) => local.query(e.sql, e.vars) as unknown as Promise<unknown>,
|
|
196
|
+
'local.execute': async (e: any) => e.query.extract((await local.query(e.query.sql, e.vars)) as unknown[]),
|
|
197
|
+
'remote.query': async (e: any) => {
|
|
198
|
+
const results = (await remote.queryResponses(e.sql, e.vars)) as StatementResult[];
|
|
199
|
+
for (const r of results) if (r.status === 'ERR') statementErrors.push(r.error);
|
|
200
|
+
if (opts.loseAnswer) throw new Error(`Remote request timed out after ${e.timeoutMs}ms`);
|
|
201
|
+
return results;
|
|
202
|
+
},
|
|
203
|
+
'ssp.ingest': () => undefined,
|
|
204
|
+
id: () => mintMutationId(state.tabId),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const remoteRow = async (id: RecordId<string>) =>
|
|
209
|
+
((await remote.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
|
|
210
|
+
const localRow = async (id: RecordId<string>) =>
|
|
211
|
+
((await local.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
|
|
212
|
+
const rolledBack = (emitted: OutEvent[]) => emitted.filter((e) => e.type === 'mutation:rolled-back');
|
|
213
|
+
const rid = (recordId: string) => new RecordId('item', recordId.slice('item:'.length));
|
|
214
|
+
const setEndpoint = (url: string) =>
|
|
215
|
+
remote.query(`DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${url}' PERMISSIONS NONE`);
|
|
216
|
+
|
|
217
|
+
/** Queue creates locally, the way `db.create` does, and return the state with them in the outbox. */
|
|
218
|
+
async function queueCreate(...recordIds: string[]): Promise<ClientState> {
|
|
219
|
+
let state = buildState();
|
|
220
|
+
for (const recordId of recordIds) {
|
|
221
|
+
state = (await run(write(env, { kind: 'create', recordId, data: { title: recordId } }), state)).state;
|
|
222
|
+
}
|
|
223
|
+
expect(state.outbox).toHaveLength(recordIds.length);
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The outbox of `state`, loaded again from the local store the way a
|
|
229
|
+
* restarted client does (without what other tests left in the store).
|
|
230
|
+
*/
|
|
231
|
+
async function reloaded(state: ClientState): Promise<ClientState> {
|
|
232
|
+
const ids = new Set(state.outbox.map((i) => i.id));
|
|
233
|
+
const loaded = (await run(loadOutbox(env), { ...buildState(), tabId: state.tabId })).state;
|
|
234
|
+
return { ...loaded, outbox: loaded.outbox.filter((i) => ids.has(i.id)) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it.for([
|
|
238
|
+
['a refused connection', 'refused'],
|
|
239
|
+
['a post that hangs past the event TIMEOUT', 'hang'],
|
|
240
|
+
['a 500 from /ingest', '500'],
|
|
241
|
+
['a 503 from a cold SSP', '503'],
|
|
242
|
+
] as const)('%s: the write stays queued, then lands once /ingest answers', { timeout: 30_000 }, async ([, failure], ctx) => {
|
|
243
|
+
if (!reachable) ctx.skip();
|
|
244
|
+
const id = new RecordId('item', `outage_${failure}`);
|
|
245
|
+
const state = await queueCreate(`item:outage_${failure}`);
|
|
246
|
+
const errorsBefore = statementErrors.length;
|
|
247
|
+
|
|
248
|
+
await setEndpoint(failure === 'refused' ? `http://${sinkHost}:${await closedPort()}` : sinkUrl);
|
|
249
|
+
sink.mode.current = failure === 'refused' ? 'ok' : failure;
|
|
250
|
+
const failed = await run(drain(env), state);
|
|
251
|
+
|
|
252
|
+
const seen = statementErrors.slice(errorsBefore);
|
|
253
|
+
expect(seen).toHaveLength(1);
|
|
254
|
+
expect(seen[0]).toMatch(
|
|
255
|
+
/Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: /
|
|
256
|
+
);
|
|
257
|
+
expect(rolledBack(failed.emitted)).toEqual([]);
|
|
258
|
+
expect(failed.state.outbox).toEqual([expect.objectContaining({ recordId: `item:outage_${failure}`, attempts: 1 })]);
|
|
259
|
+
expect(failed.timers.get('outbox')).toEqual({ ms: expect.any(Number), event: { type: 'Drain' } });
|
|
260
|
+
expect(await localRow(id)).toMatchObject({ title: `item:outage_${failure}` });
|
|
261
|
+
expect(await remoteRow(id)).toBeNull();
|
|
262
|
+
|
|
263
|
+
// The scheduler is back: the retry lands the write exactly once.
|
|
264
|
+
await setEndpoint(sinkUrl);
|
|
265
|
+
sink.mode.current = 'ok';
|
|
266
|
+
const landed = await run(drain(env), failed.state);
|
|
267
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
268
|
+
expect(landed.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:settled', recordId: `item:outage_${failure}` }));
|
|
269
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
270
|
+
expect(await remoteRow(id)).toMatchObject({ title: `item:outage_${failure}` });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('an ingest post that fails once mid-batch: the batch stops there, then every write lands once', { timeout: 30_000 }, async (ctx) => {
|
|
274
|
+
if (!reachable) ctx.skip();
|
|
275
|
+
const tag = Math.random().toString(36).slice(2, 8);
|
|
276
|
+
const [a, b] = [`item:flap_a_${tag}`, `item:flap_b_${tag}`];
|
|
277
|
+
const state = await queueCreate(a, b);
|
|
278
|
+
const errorsBefore = statementErrors.length;
|
|
279
|
+
|
|
280
|
+
// The first post (item a's) answers 503, every later one 200. Statements
|
|
281
|
+
// used to run independently, so b committed while a was refused, stayed
|
|
282
|
+
// queued behind a's cut, and its re-sent CREATE then failed "already
|
|
283
|
+
// exists" and was rolled back locally while the server kept the row.
|
|
284
|
+
await setEndpoint(sinkUrl);
|
|
285
|
+
sink.mode.current = 'flap';
|
|
286
|
+
const cut = await run(drain(env), state);
|
|
287
|
+
const seen = statementErrors.slice(errorsBefore);
|
|
288
|
+
expect(seen).toHaveLength(2);
|
|
289
|
+
expect(seen[0]).toMatch(/Error while processing event _00_item_mutation: .*503 Service Unavailable/);
|
|
290
|
+
expect(seen[1]).toContain(BATCH_SKIPPED);
|
|
291
|
+
expect(rolledBack(cut.emitted)).toEqual([]);
|
|
292
|
+
expect(cut.state.outbox.map((i) => [i.recordId, i.status])).toEqual([
|
|
293
|
+
[a, 'pending'],
|
|
294
|
+
[b, 'pending'],
|
|
295
|
+
]);
|
|
296
|
+
expect(await remoteRow(rid(a))).toBeNull();
|
|
297
|
+
expect(await remoteRow(rid(b))).toBeNull();
|
|
298
|
+
|
|
299
|
+
const landed = await run(drain(env), cut.state);
|
|
300
|
+
expect(statementErrors.slice(errorsBefore + 2)).toEqual([]);
|
|
301
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
302
|
+
expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b]);
|
|
303
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
304
|
+
expect(landed.state.failedCount).toBe(0);
|
|
305
|
+
for (const id of [a, b]) {
|
|
306
|
+
expect(await remoteRow(rid(id))).toMatchObject({ title: id });
|
|
307
|
+
expect(await localRow(rid(id))).toMatchObject({ title: id });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it.for([['in the same session'], ['after a restart']] as const)(
|
|
312
|
+
'a push whose answer is lost after the server committed, re-sent %s: nothing rolls back, every write lands once',
|
|
313
|
+
{ timeout: 30_000 },
|
|
314
|
+
async ([when], ctx) => {
|
|
315
|
+
if (!reachable) ctx.skip();
|
|
316
|
+
await setEndpoint(sinkUrl);
|
|
317
|
+
sink.mode.current = 'ok';
|
|
318
|
+
const tag = Math.random().toString(36).slice(2, 8);
|
|
319
|
+
const [a, b] = [`item:lost_a_${tag}`, `item:lost_b_${tag}`];
|
|
320
|
+
let state = await queueCreate(a, b);
|
|
321
|
+
// Edited before the push: the batch carries a's CREATE and then its UPDATE.
|
|
322
|
+
state = (await run(write(env, { kind: 'update', recordId: a, data: { title: 'edited' } }), state)).state;
|
|
323
|
+
const [errorsBefore, postsBefore] = [statementErrors.length, sink.hits.length];
|
|
324
|
+
|
|
325
|
+
const lost = await run(drain(env), state, { loseAnswer: true });
|
|
326
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([]);
|
|
327
|
+
expect(sink.hits.length - postsBefore).toBe(3); // the server ran CREATE a, CREATE b, UPDATE a
|
|
328
|
+
expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
329
|
+
expect(await remoteRow(rid(b))).toMatchObject({ title: b });
|
|
330
|
+
expect(rolledBack(lost.emitted)).toEqual([]);
|
|
331
|
+
expect(lost.state.outbox.map((i) => [i.recordId, i.status, i.inDoubt])).toEqual([
|
|
332
|
+
[a, 'pending', true],
|
|
333
|
+
[b, 'pending', true],
|
|
334
|
+
[a, 'pending', true],
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
// The re-sent CREATEs used to fail "already exists" and roll back: the
|
|
338
|
+
// local rows deleted, two false entries in the tray, the server keeping both.
|
|
339
|
+
const retry = when === 'after a restart' ? await reloaded(lost.state) : lost.state;
|
|
340
|
+
const landed = await run(drain(env), retry);
|
|
341
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([]);
|
|
342
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
343
|
+
expect(landed.state.failedCount).toBe(retry.failedCount);
|
|
344
|
+
expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b, a]);
|
|
345
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
346
|
+
// Once: the re-send changed nothing on the server, so no event fired again.
|
|
347
|
+
expect(sink.hits.length - postsBefore).toBe(3);
|
|
348
|
+
expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
349
|
+
expect(await remoteRow(rid(b))).toMatchObject({ title: b });
|
|
350
|
+
expect(await localRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
351
|
+
expect(await localRow(rid(b))).toMatchObject({ title: b });
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
it('an in-doubt CREATE whose id someone else holds with other content still rolls back', { timeout: 30_000 }, async (ctx) => {
|
|
356
|
+
if (!reachable) ctx.skip();
|
|
357
|
+
await setEndpoint(sinkUrl);
|
|
358
|
+
sink.mode.current = 'ok';
|
|
359
|
+
const recordId = `item:taken_${Math.random().toString(36).slice(2, 8)}`;
|
|
360
|
+
await remote.query('CREATE ONLY $id SET title = "theirs"', { id: rid(recordId) });
|
|
361
|
+
const state = await queueCreate(recordId);
|
|
362
|
+
const errorsBefore = statementErrors.length;
|
|
363
|
+
|
|
364
|
+
const lost = await run(drain(env), state, { loseAnswer: true });
|
|
365
|
+
expect(lost.state.outbox).toEqual([expect.objectContaining({ recordId, inDoubt: true })]);
|
|
366
|
+
const res = await run(drain(env), lost.state);
|
|
367
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([
|
|
368
|
+
expect.stringMatching(/already exists/),
|
|
369
|
+
expect.stringMatching(/already exists/),
|
|
370
|
+
]);
|
|
371
|
+
expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(/already exists/) })]);
|
|
372
|
+
expect(res.state.outbox).toEqual([]);
|
|
373
|
+
expect(await remoteRow(rid(recordId))).toMatchObject({ title: 'theirs' });
|
|
374
|
+
expect(await localRow(rid(recordId))).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it.for([
|
|
378
|
+
[
|
|
379
|
+
"a failing http::post in the app's own event",
|
|
380
|
+
`DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { SELECT * FROM http::post('${'SINK'}/crm', {}) TIMEOUT 2s; };`,
|
|
381
|
+
/Error while processing event notify_crm: There was an error processing a remote HTTP request: 500/,
|
|
382
|
+
],
|
|
383
|
+
[
|
|
384
|
+
"a THROW from the app's own event",
|
|
385
|
+
`DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { THROW 'lead refused'; };`,
|
|
386
|
+
/lead refused/,
|
|
387
|
+
],
|
|
388
|
+
] as const)('%s still rolls the write back', { timeout: 30_000 }, async ([, ddl, expected], ctx) => {
|
|
389
|
+
if (!reachable) ctx.skip();
|
|
390
|
+
await setEndpoint(sinkUrl);
|
|
391
|
+
await remote.query(ddl.replace('SINK', sinkUrl));
|
|
392
|
+
sink.mode.current = '500';
|
|
393
|
+
const recordId = `lead:${Math.random().toString(36).slice(2, 8)}`;
|
|
394
|
+
const state = await queueCreate(recordId);
|
|
395
|
+
const res = await run(drain(env), state);
|
|
396
|
+
sink.mode.current = 'ok';
|
|
397
|
+
expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(expected) })]);
|
|
398
|
+
expect(res.state.outbox).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildJobRecord } from './jobs';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
tables: [],
|
|
6
|
+
backends: {
|
|
7
|
+
api: { outboxTable: '_00_job_api', routes: { hello: { args: { name: { optional: false }, tone: { optional: true } } } } },
|
|
8
|
+
bare: { routes: { x: { args: {} } } },
|
|
9
|
+
},
|
|
10
|
+
} as any;
|
|
11
|
+
|
|
12
|
+
describe('buildJobRecord', () => {
|
|
13
|
+
it('builds a pending job row with the route payload and option overrides', () => {
|
|
14
|
+
const { tableName, record } = buildJobRecord(schema, 'api', 'hello', { name: 'a', extra: 1 }, { max_retries: 5, retry_strategy: 'exponential', timeout: 9, delay: 100, assignedTo: 'w1' });
|
|
15
|
+
expect(tableName).toBe('_00_job_api');
|
|
16
|
+
expect(record).toEqual({ path: 'hello', payload: JSON.stringify({ name: 'a', tone: undefined }), status: 'pending', max_retries: 5, retry_strategy: 'exponential', timeout: 9, delay: 100, assigned_to: 'w1' });
|
|
17
|
+
expect(buildJobRecord(schema, 'api', 'hello', { name: 'a' }).record).toMatchObject({ max_retries: 3, retry_strategy: 'linear' });
|
|
18
|
+
});
|
|
19
|
+
it('rejects unknown routes, missing outbox tables and missing required args', () => {
|
|
20
|
+
expect(() => buildJobRecord(schema, 'api', 'nope', {})).toThrow('Route api.nope not found');
|
|
21
|
+
expect(() => buildJobRecord(schema, 'bare', 'x', {})).toThrow('Outbox table for backend bare not found');
|
|
22
|
+
expect(() => buildJobRecord(schema, 'api', 'hello', {})).toThrow('Missing required argument name');
|
|
23
|
+
expect(() => buildJobRecord({ tables: [] } as any, 'api', 'hello', {})).toThrow('Route api.hello not found');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { RunOptions } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build the outbox job record for a backend route and resolve its table.
|
|
6
|
+
* Every job is a single execution; recurring work is declared server-side.
|
|
7
|
+
*/
|
|
8
|
+
export function buildJobRecord(
|
|
9
|
+
schema: SchemaStructure,
|
|
10
|
+
backend: string,
|
|
11
|
+
path: string,
|
|
12
|
+
data: Record<string, unknown>,
|
|
13
|
+
options?: RunOptions
|
|
14
|
+
): { tableName: string; record: Record<string, unknown> } {
|
|
15
|
+
const backends = (schema as { backends?: Record<string, { outboxTable?: string; routes?: Record<string, { args: Record<string, { optional: boolean }> }> }> }).backends;
|
|
16
|
+
const route = backends?.[backend]?.routes?.[path];
|
|
17
|
+
if (!route) throw new Error(`Route ${backend}.${path} not found`);
|
|
18
|
+
const tableName = backends?.[backend]?.outboxTable;
|
|
19
|
+
if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
|
|
20
|
+
|
|
21
|
+
const payload: Record<string, unknown> = {};
|
|
22
|
+
for (const argName of Object.keys(route.args)) {
|
|
23
|
+
const arg = route.args[argName];
|
|
24
|
+
if (data[argName] === undefined && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
|
|
25
|
+
payload[argName] = data[argName];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const record: Record<string, unknown> = {
|
|
29
|
+
path,
|
|
30
|
+
payload: JSON.stringify(payload),
|
|
31
|
+
// Explicit: the schema's server-side DEFAULT does not apply to an
|
|
32
|
+
// optimistic local create, and in-flight indicators key on `pending`.
|
|
33
|
+
status: 'pending',
|
|
34
|
+
max_retries: options?.max_retries ?? 3,
|
|
35
|
+
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
36
|
+
};
|
|
37
|
+
if (options?.timeout != null) record.timeout = options.timeout;
|
|
38
|
+
if (options?.delay != null) record.delay = options.delay;
|
|
39
|
+
if (options?.assignedTo) record.assigned_to = options.assignedTo;
|
|
40
|
+
return { tableName, record };
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mintMutationId, mutationOwnerTabId } from './mutation-id';
|
|
3
|
+
|
|
4
|
+
describe('mutationOwnerTabId (raw ids)', () => {
|
|
5
|
+
it('mints with the fallback tab id when none is given', () => {
|
|
6
|
+
const id = mintMutationId();
|
|
7
|
+
expect(id.startsWith('_00_pending_mutations:')).toBe(true);
|
|
8
|
+
expect(mutationOwnerTabId(id)).toMatch(/^[a-z0-9]+$/);
|
|
9
|
+
});
|
|
10
|
+
it('accepts an id without the table prefix', () => {
|
|
11
|
+
expect(mutationOwnerTabId('1700000000000_0001_tab9')).toBe('tab9');
|
|
12
|
+
expect(mutationOwnerTabId('1700000000000')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// The old `_00_pending_mutations:${Date.now()}` collided within a millisecond
|
|
17
|
+
// (guaranteed once multiple tabs share a store) and never actually ordered the
|
|
18
|
+
// drain. These pin the new id's three properties: unique, sortable, routable.
|
|
19
|
+
describe('mutation ids', () => {
|
|
20
|
+
it('never collides within a burst', () => {
|
|
21
|
+
const ids = new Set(Array.from({ length: 1000 }, () => mintMutationId('tabx')));
|
|
22
|
+
expect(ids.size).toBe(1000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('sorts lexicographically in mint order (ORDER BY id is chronological)', () => {
|
|
26
|
+
const ids = Array.from({ length: 200 }, () => mintMutationId('tabx'));
|
|
27
|
+
expect([...ids].sort()).toEqual(ids);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('routes back to the owning tab', () => {
|
|
31
|
+
expect(mutationOwnerTabId(mintMutationId('tab-abc'))).toBe('tab-abc');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns null for a legacy numeric id instead of misrouting', () => {
|
|
35
|
+
expect(mutationOwnerTabId('_00_pending_mutations:1753875000000')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox mutation ids. Previously `_00_pending_mutations:${Date.now()}`, which
|
|
3
|
+
* (a) collides when two mutations land in the same millisecond, a real case
|
|
4
|
+
* once multiple tabs share one store, and (b) never actually ordered the
|
|
5
|
+
* drain, because `loadFromDatabase` sorted by a `created_at` field that does
|
|
6
|
+
* not exist on the table. The new shape is sortable AND collision-free:
|
|
7
|
+
*
|
|
8
|
+
* <13-digit zero-padded ms timestamp>_<4-digit base36 seq>_<tabId>
|
|
9
|
+
*
|
|
10
|
+
* Lexicographic order == chronological order (per tab; cross-tab within-ms
|
|
11
|
+
* ordering is arbitrary, which matches reality), so `ORDER BY id` in the
|
|
12
|
+
* drain is now meaningful. The tabId suffix also routes rollbacks back to the
|
|
13
|
+
* tab that owns the mutation in shared-tabs mode.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let seq = 0;
|
|
17
|
+
|
|
18
|
+
/** Tab identity for mutation ids in SOLO mode, minted once per session. In
|
|
19
|
+
* shared-tabs mode the coordinator's tabId is passed in instead. */
|
|
20
|
+
const FALLBACK_TAB_ID = Math.random().toString(36).slice(2, 8);
|
|
21
|
+
|
|
22
|
+
export function mintMutationId(tabId?: string): string {
|
|
23
|
+
const ts = Date.now().toString().padStart(13, '0');
|
|
24
|
+
const n = (seq = (seq + 1) % 36 ** 4).toString(36).padStart(4, '0');
|
|
25
|
+
return `_00_pending_mutations:${ts}_${n}_${tabId ?? FALLBACK_TAB_ID}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tab that created the mutation, or null for a legacy numeric id. */
|
|
29
|
+
export function mutationOwnerTabId(mutationId: string): string | null {
|
|
30
|
+
const raw = mutationId.startsWith('_00_pending_mutations:')
|
|
31
|
+
? mutationId.slice('_00_pending_mutations:'.length)
|
|
32
|
+
: mutationId;
|
|
33
|
+
const parts = raw.split('_');
|
|
34
|
+
return parts.length >= 3 ? parts[parts.length - 1] : null;
|
|
35
|
+
}
|