@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.241
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 +34 -15
- package/README.md +56 -16
- package/dist/index.d.ts +1136 -1846
- package/dist/index.js +10168 -11267
- package/dist/types.d.ts +22 -79
- package/package.json +8 -4
- package/skills/sp00ky-core/SKILL.md +18 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +56 -0
- package/src/client/router.ts +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +349 -0
- package/src/client/sp00ky-client.test.ts +241 -0
- package/src/client/sp00ky-client.ts +482 -0
- package/src/index.ts +1 -0
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +143 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/modules/app-release/index.ts +13 -5
- package/src/modules/devtools/index.ts +10 -3
- package/src/modules/devtools/state-shape.test.ts +4 -1
- package/src/modules/feature-flag/index.ts +13 -5
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -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 +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +109 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
- package/src/query/lifecycle.saga.test.ts +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +251 -0
- package/src/query/register.saga.ts +190 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -0
- package/src/{modules/data → query}/window-query.test.ts +18 -0
- package/src/{modules/data → query}/window-query.ts +2 -1
- package/src/services/database/connection-supervisor.test.ts +29 -0
- package/src/services/database/connection-supervisor.ts +7 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +25 -10
- package/src/services/database/local-migrator.ts +2 -2
- package/src/services/database/plan-render.test.ts +1 -1
- package/src/services/database/sqlite-cache-engine.test.ts +4 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -11
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
- package/src/services/tabs/protocol.ts +7 -3
- package/src/sp00ky.ts +5 -1854
- package/src/state/client-state.ts +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +69 -0
- package/src/sync/live.saga.ts +49 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
- package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +74 -0
- package/src/sync/tabs.saga.ts +92 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +61 -0
- package/src/testing/fake-services.ts +34 -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 +8 -7
- package/src/utils/utils.test.ts +2 -1
- package/vitest.config.ts +48 -0
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -476
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
- package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
- package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
- package/coverage/lcov-report/src/boot/index.html +0 -161
- package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
- package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
- package/coverage/lcov-report/src/client/index.html +0 -161
- package/coverage/lcov-report/src/client/router.ts.html +0 -367
- package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
- package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
- package/coverage/lcov-report/src/events/index.html +0 -116
- package/coverage/lcov-report/src/events/index.ts.html +0 -877
- package/coverage/lcov-report/src/index.html +0 -146
- package/coverage/lcov-report/src/index.ts.html +0 -202
- package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
- package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
- package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
- package/coverage/lcov-report/src/kernel/index.html +0 -176
- package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
- package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
- package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
- package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
- package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
- package/coverage/lcov-report/src/modules/auth/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
- package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
- package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
- package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
- package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
- package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
- package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
- package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
- package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
- package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
- package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
- package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
- package/coverage/lcov-report/src/modules/index.html +0 -116
- package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
- package/coverage/lcov-report/src/mutation/index.html +0 -191
- package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
- package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
- package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
- package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
- package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
- package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
- package/coverage/lcov-report/src/otel/index.html +0 -116
- package/coverage/lcov-report/src/otel/index.ts.html +0 -466
- package/coverage/lcov-report/src/query/env.ts.html +0 -253
- package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
- package/coverage/lcov-report/src/query/hash.ts.html +0 -139
- package/coverage/lcov-report/src/query/index.html +0 -281
- package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
- package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
- package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
- package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
- package/coverage/lcov-report/src/query/membership.ts.html +0 -559
- package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
- package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
- package/coverage/lcov-report/src/query/sql.ts.html +0 -322
- package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
- package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
- package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
- package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
- package/coverage/lcov-report/src/services/blobs/index.html +0 -161
- package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
- package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
- package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
- package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
- package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
- package/coverage/lcov-report/src/services/database/events/index.html +0 -116
- package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/index.html +0 -386
- package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
- package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
- package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
- package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
- package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
- package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
- package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
- package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
- package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
- package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
- package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
- package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
- package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
- package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
- package/coverage/lcov-report/src/services/logger/index.html +0 -116
- package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
- package/coverage/lcov-report/src/services/persistence/index.html +0 -146
- package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
- package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
- package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
- package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
- package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
- package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
- package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
- package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
- package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
- package/coverage/lcov-report/src/services/tabs/index.html +0 -191
- package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
- package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
- package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
- package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
- package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
- package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
- package/coverage/lcov-report/src/state/index.html +0 -161
- package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
- package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
- package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
- package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
- package/coverage/lcov-report/src/sync/index.html +0 -176
- package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
- package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
- package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
- package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
- package/coverage/lcov-report/src/types.ts.html +0 -2419
- package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
- package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
- package/coverage/lcov-report/src/utils/index.html +0 -191
- package/coverage/lcov-report/src/utils/index.ts.html +0 -820
- package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
- package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
- package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
- package/coverage/lcov.info +0 -19079
- package/src/modules/cache/cache.relay.test.ts +0 -95
- package/src/modules/cache/index.ts +0 -361
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.activity.test.ts +0 -109
- package/src/modules/data/data.authority.test.ts +0 -122
- package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
- package/src/modules/data/data.hydration.test.ts +0 -142
- package/src/modules/data/data.membership.test.ts +0 -623
- package/src/modules/data/data.notify-table.test.ts +0 -41
- package/src/modules/data/data.pending-ids.test.ts +0 -199
- package/src/modules/data/data.rebind.test.ts +0 -170
- package/src/modules/data/data.rematerialize.test.ts +0 -114
- package/src/modules/data/data.run.test.ts +0 -113
- package/src/modules/data/data.settled-writes.test.ts +0 -235
- package/src/modules/data/data.status.test.ts +0 -249
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -2745
- package/src/modules/sync/engine.ts +0 -250
- package/src/modules/sync/events/index.ts +0 -84
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.test.ts +0 -228
- package/src/modules/sync/queue/queue-down.ts +0 -184
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
- package/src/modules/sync/queue/queue-up.ts +0 -407
- package/src/modules/sync/scheduler.pause.test.ts +0 -109
- package/src/modules/sync/scheduler.retry.test.ts +0 -237
- package/src/modules/sync/scheduler.ts +0 -286
- package/src/modules/sync/sync.cleanup.test.ts +0 -116
- package/src/modules/sync/sync.health.test.ts +0 -167
- package/src/modules/sync/sync.heartbeat.test.ts +0 -80
- package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
- package/src/modules/sync/sync.live-removal.test.ts +0 -289
- package/src/modules/sync/sync.reconnect.test.ts +0 -145
- package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
- package/src/modules/sync/sync.settled-poll.test.ts +0 -58
- package/src/modules/sync/sync.subquery.test.ts +0 -82
- package/src/modules/sync/sync.tabs.test.ts +0 -249
- package/src/modules/sync/sync.ts +0 -2626
- package/src/sp00ky.auth-order.test.ts +0 -112
- package/src/sp00ky.init-query.test.ts +0 -183
- package/src/sp00ky.local-first.test.ts +0 -60
- /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import type { MutationEventType } from '../types';
|
|
3
|
+
import type { Vars } from '../kernel/effects';
|
|
4
|
+
import type { IngestRecord } from '../services/stream-processor/index';
|
|
5
|
+
import type { OutboxItem } from '../state/client-state';
|
|
6
|
+
import { surql } from '../utils/surql';
|
|
7
|
+
import type { SealedQuery } from '../utils/surql';
|
|
8
|
+
import { extractTablePart, parseRecordIdString } from '../utils/index';
|
|
9
|
+
import { parseQueryParams } from '../utils/parser';
|
|
10
|
+
|
|
11
|
+
export const PENDING_TABLE = '_00_pending_mutations';
|
|
12
|
+
export const FAILED_TABLE = '_00_failed_mutations';
|
|
13
|
+
export const PENDING_ROW_VERSION = 2;
|
|
14
|
+
|
|
15
|
+
/** One `_00_pending_mutations` row as this client writes it (v2). */
|
|
16
|
+
export interface PendingMutationRow {
|
|
17
|
+
id: string;
|
|
18
|
+
mutationType: MutationEventType;
|
|
19
|
+
recordId: string;
|
|
20
|
+
tableName: string;
|
|
21
|
+
data?: Record<string, unknown>;
|
|
22
|
+
beforeRecord?: Record<string, unknown> | null;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
v: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FailedMutationRow {
|
|
28
|
+
id: string;
|
|
29
|
+
mutationType: MutationEventType;
|
|
30
|
+
recordId: string;
|
|
31
|
+
tableName: string;
|
|
32
|
+
data?: Record<string, unknown>;
|
|
33
|
+
beforeRecord?: Record<string, unknown> | null;
|
|
34
|
+
error: { message: string; kind: 'application' | 'unreplayable' };
|
|
35
|
+
attempts: number;
|
|
36
|
+
createdAt: number;
|
|
37
|
+
failedAt: number;
|
|
38
|
+
revert: 'full' | 'partial' | 'none';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
|
|
43
|
+
* escaping (outbox ids contain `_`, so they come back escaped).
|
|
44
|
+
*/
|
|
45
|
+
export function parseStoredRecordId(id: string): RecordId<string> {
|
|
46
|
+
const [table, ...rest] = id.split(':');
|
|
47
|
+
let raw = rest.join(':');
|
|
48
|
+
if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
|
|
49
|
+
return new RecordId(table, raw);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stable `table:id` string for an id read from the store, escaping removed. */
|
|
53
|
+
export const storedIdString = (id: unknown): string => {
|
|
54
|
+
if (id instanceof RecordId) return `${id.table}:${String(id.id)}`;
|
|
55
|
+
const rid = parseStoredRecordId(String(id));
|
|
56
|
+
return `${rid.table}:${rid.id}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
|
|
60
|
+
export function createdAtFromId(id: string): number | null {
|
|
61
|
+
const raw = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
|
|
62
|
+
const m = /^⟨?(\d{13})/.exec(raw);
|
|
63
|
+
return m ? Number(m[1]) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read any row shape this table ever had. Legacy rows (`v` missing) lack
|
|
68
|
+
* `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
|
|
69
|
+
* replayable and returns `null`.
|
|
70
|
+
*/
|
|
71
|
+
export function parsePendingRow(row: unknown): PendingMutationRow | null {
|
|
72
|
+
if (!row || typeof row !== 'object') return null;
|
|
73
|
+
const r = row as Record<string, unknown>;
|
|
74
|
+
const type = r.mutationType;
|
|
75
|
+
if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
|
|
76
|
+
if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
|
|
77
|
+
const id = storedIdString(r.id);
|
|
78
|
+
const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
|
|
79
|
+
const data = r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined;
|
|
80
|
+
if (type === 'create' && !data) return null;
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
mutationType: type,
|
|
84
|
+
recordId,
|
|
85
|
+
tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
|
|
86
|
+
data,
|
|
87
|
+
beforeRecord:
|
|
88
|
+
r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : undefined,
|
|
89
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : (createdAtFromId(id) ?? 0),
|
|
90
|
+
v: typeof r.v === 'number' ? r.v : 1,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Re-type a stored row's payload from the schema before it is pushed. The
|
|
96
|
+
* store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
|
|
97
|
+
* datetime as a string; the server coerces neither. Unknown keys stay as they
|
|
98
|
+
* are, and a payload the schema rejects is sent raw so the server's answer,
|
|
99
|
+
* not a local throw, decides its fate.
|
|
100
|
+
*/
|
|
101
|
+
export function hydrateRowData(row: PendingMutationRow, columns: Record<string, unknown> | null): PendingMutationRow {
|
|
102
|
+
if (!row.data || !columns) return row;
|
|
103
|
+
try {
|
|
104
|
+
return { ...row, data: parseQueryParams(columns as never, row.data) };
|
|
105
|
+
} catch {
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
|
|
111
|
+
id: row.id,
|
|
112
|
+
type: row.mutationType,
|
|
113
|
+
recordId: row.recordId,
|
|
114
|
+
table: row.tableName,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
ackedAt: null,
|
|
117
|
+
attempts: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const loadPendingRows = (): string => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
|
|
121
|
+
export const loadFailedRows = (): string => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
|
|
122
|
+
|
|
123
|
+
// ---- local write transactions ------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export interface LocalTx {
|
|
126
|
+
query: SealedQuery<unknown>;
|
|
127
|
+
vars: Vars;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pendingInsert = (type: MutationEventType, withData: boolean, withBefore: boolean): string => {
|
|
131
|
+
const fields = [
|
|
132
|
+
`mutationType = '${type}'`,
|
|
133
|
+
'recordId = $id',
|
|
134
|
+
'tableName = $table',
|
|
135
|
+
'createdAt = $createdAt',
|
|
136
|
+
`v = ${PENDING_ROW_VERSION}`,
|
|
137
|
+
];
|
|
138
|
+
if (withData) fields.push('data = $data');
|
|
139
|
+
if (withBefore) fields.push('beforeRecord = $before');
|
|
140
|
+
return `CREATE ONLY $mid SET ${fields.join(', ')}`;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export interface WritePlanInput {
|
|
144
|
+
recordId: RecordId<string>;
|
|
145
|
+
mutationId: RecordId<string>;
|
|
146
|
+
table: string;
|
|
147
|
+
data?: Record<string, unknown>;
|
|
148
|
+
before?: Record<string, unknown> | null;
|
|
149
|
+
now: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
|
|
153
|
+
export function planCreateTx(input: WritePlanInput): LocalTx {
|
|
154
|
+
const data = input.data ?? {};
|
|
155
|
+
const keys = Object.keys(data).map((key) => ({ key, variable: `data_${key}` }));
|
|
156
|
+
const vars: Vars = { id: input.recordId, mid: input.mutationId, table: input.table, createdAt: input.now, data };
|
|
157
|
+
for (const { key, variable } of keys) vars[variable] = data[key];
|
|
158
|
+
return {
|
|
159
|
+
query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys), pendingInsert('create', true, false)]), {
|
|
160
|
+
resultIndex: 0,
|
|
161
|
+
}),
|
|
162
|
+
vars,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
|
|
167
|
+
export function planUpdateTx(input: WritePlanInput): LocalTx {
|
|
168
|
+
return {
|
|
169
|
+
query: surql.seal<unknown>(
|
|
170
|
+
surql.tx([
|
|
171
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
172
|
+
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
173
|
+
pendingInsert('update', true, true),
|
|
174
|
+
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
175
|
+
])
|
|
176
|
+
),
|
|
177
|
+
vars: {
|
|
178
|
+
id: input.recordId,
|
|
179
|
+
mid: input.mutationId,
|
|
180
|
+
table: input.table,
|
|
181
|
+
createdAt: input.now,
|
|
182
|
+
data: input.data ?? {},
|
|
183
|
+
before: input.before ?? null,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** DELETE the row and write the outbox entry (with `beforeRecord`). */
|
|
189
|
+
export function planDeleteTx(input: WritePlanInput): LocalTx {
|
|
190
|
+
return {
|
|
191
|
+
query: surql.seal<unknown>(surql.tx([surql.delete('id'), pendingInsert('delete', false, true)])),
|
|
192
|
+
vars: {
|
|
193
|
+
id: input.recordId,
|
|
194
|
+
mid: input.mutationId,
|
|
195
|
+
table: input.table,
|
|
196
|
+
createdAt: input.now,
|
|
197
|
+
before: input.before ?? null,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later. */
|
|
203
|
+
export function planLocalOnlyUpdateTx(input: { recordId: RecordId<string>; data: Record<string, unknown> }): LocalTx {
|
|
204
|
+
return {
|
|
205
|
+
query: surql.seal<unknown>(
|
|
206
|
+
surql.tx([
|
|
207
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
208
|
+
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
209
|
+
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
210
|
+
])
|
|
211
|
+
),
|
|
212
|
+
vars: { id: input.recordId, data: input.data },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The outbox row of a flushed debounced update, on its own. */
|
|
217
|
+
export function planDeferredOutboxRowTx(input: WritePlanInput): LocalTx {
|
|
218
|
+
return {
|
|
219
|
+
query: surql.seal<unknown>(surql.tx([pendingInsert('update', true, true)])),
|
|
220
|
+
vars: {
|
|
221
|
+
id: input.recordId,
|
|
222
|
+
mid: input.mutationId,
|
|
223
|
+
table: input.table,
|
|
224
|
+
createdAt: input.now,
|
|
225
|
+
data: input.data ?? {},
|
|
226
|
+
before: input.before ?? null,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- remote push -------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
export interface RemoteBatch {
|
|
234
|
+
sql: string;
|
|
235
|
+
vars: Vars;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Many outbox rows as ONE multi-statement request. Statement i answers row i.
|
|
240
|
+
* Each statement is its own transaction on the server, as before.
|
|
241
|
+
*/
|
|
242
|
+
export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>): RemoteBatch {
|
|
243
|
+
const vars: Vars = {};
|
|
244
|
+
const stmts = rows.map((row, i) => {
|
|
245
|
+
const rid = parseRecordIdString(row.recordId);
|
|
246
|
+
vars[`id${i}`] = rid;
|
|
247
|
+
switch (row.mutationType) {
|
|
248
|
+
case 'create': {
|
|
249
|
+
const data = row.data ?? {};
|
|
250
|
+
const sets = Object.keys(data).map((key) => {
|
|
251
|
+
vars[`d${i}_${key}`] = data[key];
|
|
252
|
+
return `${key} = $d${i}_${key}`;
|
|
253
|
+
});
|
|
254
|
+
return `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
|
|
255
|
+
}
|
|
256
|
+
case 'update':
|
|
257
|
+
vars[`data${i}`] = row.data ?? {};
|
|
258
|
+
return `UPDATE $id${i} MERGE $data${i}`;
|
|
259
|
+
case 'delete':
|
|
260
|
+
return `DELETE $id${i}`;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return { sql: stmts.join(';\n'), vars };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- rollback ----------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
export interface RevertPlan {
|
|
269
|
+
tx: LocalTx | null;
|
|
270
|
+
circuit: IngestRecord;
|
|
271
|
+
revert: FailedMutationRow['revert'];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* How to undo a rejected mutation locally. A create is deleted; an update or
|
|
276
|
+
* delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
|
|
277
|
+
* comes back). Without a `beforeRecord` nothing can be restored.
|
|
278
|
+
*/
|
|
279
|
+
export function planRevert(row: PendingMutationRow, before: Record<string, unknown> | null): RevertPlan {
|
|
280
|
+
const rid = parseRecordIdString(row.recordId);
|
|
281
|
+
if (row.mutationType === 'create') {
|
|
282
|
+
return {
|
|
283
|
+
tx: { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: rid } },
|
|
284
|
+
circuit: { table: row.tableName, op: 'DELETE', id: row.recordId, record: row.data ?? {} },
|
|
285
|
+
revert: 'full',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (!before) {
|
|
289
|
+
return {
|
|
290
|
+
tx: null,
|
|
291
|
+
circuit: { table: row.tableName, op: 'UPDATE', id: row.recordId, record: {} },
|
|
292
|
+
revert: 'partial',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const { id: _id, ...content } = before;
|
|
296
|
+
return {
|
|
297
|
+
tx: { query: surql.seal<unknown>(surql.tx([surql.upsert('id', 'content')])), vars: { id: rid, content } },
|
|
298
|
+
circuit: {
|
|
299
|
+
table: row.tableName,
|
|
300
|
+
op: row.mutationType === 'delete' ? 'CREATE' : 'UPDATE',
|
|
301
|
+
id: row.recordId,
|
|
302
|
+
record: { ...before, id: rid },
|
|
303
|
+
},
|
|
304
|
+
revert: 'full',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const failedRecordId = (mutationId: string): RecordId<string> =>
|
|
309
|
+
new RecordId(FAILED_TABLE, mutationId.slice(mutationId.indexOf(':') + 1));
|
|
310
|
+
|
|
311
|
+
export function buildFailedRow(
|
|
312
|
+
row: PendingMutationRow,
|
|
313
|
+
error: { message: string; kind: 'application' | 'unreplayable' },
|
|
314
|
+
before: Record<string, unknown> | null,
|
|
315
|
+
attempts: number,
|
|
316
|
+
now: number,
|
|
317
|
+
revert: FailedMutationRow['revert']
|
|
318
|
+
): FailedMutationRow {
|
|
319
|
+
return {
|
|
320
|
+
id: row.id,
|
|
321
|
+
mutationType: row.mutationType,
|
|
322
|
+
recordId: row.recordId,
|
|
323
|
+
tableName: row.tableName,
|
|
324
|
+
data: row.data,
|
|
325
|
+
beforeRecord: before,
|
|
326
|
+
error,
|
|
327
|
+
attempts,
|
|
328
|
+
createdAt: row.createdAt,
|
|
329
|
+
failedAt: now,
|
|
330
|
+
revert,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** ONE transaction: write the tray row, delete the pending row. */
|
|
335
|
+
export function moveToFailedTx(failed: FailedMutationRow): LocalTx {
|
|
336
|
+
const { id: _id, ...content } = failed;
|
|
337
|
+
return {
|
|
338
|
+
query: surql.seal<unknown>(surql.tx([surql.create('fid', 'failed'), surql.delete('mid')])),
|
|
339
|
+
vars: { fid: failedRecordId(failed.id), failed: content, mid: parseStoredRecordId(failed.id) },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const deletePendingRow = (mutationId: string): { sql: string; vars: Vars } => ({
|
|
344
|
+
sql: 'DELETE $mid',
|
|
345
|
+
vars: { mid: parseStoredRecordId(mutationId) },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
export const deleteFailedRow = (mutationId: string): { sql: string; vars: Vars } => ({
|
|
349
|
+
sql: 'DELETE $fid',
|
|
350
|
+
vars: { fid: failedRecordId(mutationId) },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export function parseFailedRow(row: unknown): FailedMutationRow | null {
|
|
354
|
+
if (!row || typeof row !== 'object') return null;
|
|
355
|
+
const r = row as Record<string, unknown>;
|
|
356
|
+
const type = r.mutationType;
|
|
357
|
+
if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
|
|
358
|
+
if (typeof r.recordId !== 'string') return null;
|
|
359
|
+
const err = (r.error ?? {}) as Record<string, unknown>;
|
|
360
|
+
return {
|
|
361
|
+
id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
|
|
362
|
+
mutationType: type,
|
|
363
|
+
recordId: r.recordId,
|
|
364
|
+
tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(r.recordId),
|
|
365
|
+
data: r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined,
|
|
366
|
+
beforeRecord:
|
|
367
|
+
r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
|
|
368
|
+
error: {
|
|
369
|
+
message: typeof err.message === 'string' ? err.message : 'unknown',
|
|
370
|
+
kind: err.kind === 'unreplayable' ? 'unreplayable' : 'application',
|
|
371
|
+
},
|
|
372
|
+
attempts: typeof r.attempts === 'number' ? r.attempts : 0,
|
|
373
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
374
|
+
failedAt: typeof r.failedAt === 'number' ? r.failedAt : 0,
|
|
375
|
+
revert: r.revert === 'partial' || r.revert === 'none' ? r.revert : 'full',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { buildState } from '../testing/build';
|
|
5
|
+
import * as R from '../state/reducers';
|
|
6
|
+
import { defaultEnv } from '../query/env';
|
|
7
|
+
import { discardFailed, listFailed, retryFailed } from './tray.saga';
|
|
8
|
+
|
|
9
|
+
const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {} } }] } as any);
|
|
10
|
+
const failedRow = {
|
|
11
|
+
id: '_00_failed_mutations:m1',
|
|
12
|
+
mutationType: 'create',
|
|
13
|
+
recordId: 'thing:1',
|
|
14
|
+
tableName: 'thing',
|
|
15
|
+
data: { a: 1 },
|
|
16
|
+
error: { message: 'denied', kind: 'application' },
|
|
17
|
+
attempts: 1,
|
|
18
|
+
createdAt: 1,
|
|
19
|
+
failedAt: 2,
|
|
20
|
+
revert: 'full',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('tray', () => {
|
|
24
|
+
it('lists parsed rows, skipping junk', async () => {
|
|
25
|
+
const out = await runPure(listFailed(), { handlers: { 'local.query': () => [[failedRow, { nope: true }]] } });
|
|
26
|
+
expect(out.result).toEqual([expect.objectContaining({ id: '_00_pending_mutations:m1', mutationType: 'create' })]);
|
|
27
|
+
expect((await runPure(listFailed(), { handlers: { 'local.query': () => [] } })).result).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it('retry re-applies the write with a new id, drops the tray row, updates the count; missing rows return false', async () => {
|
|
30
|
+
const out = await runPure(retryFailed(env, '_00_pending_mutations:m1'), {
|
|
31
|
+
state: R.setFailedCount(2)(buildState()),
|
|
32
|
+
handlers: {
|
|
33
|
+
'local.query': (e: any) => {
|
|
34
|
+
if (e.sql === 'SELECT * FROM $ids') {
|
|
35
|
+
expect(e.vars.ids).toEqual([new RecordId('_00_failed_mutations', 'm1')]);
|
|
36
|
+
return [[failedRow]];
|
|
37
|
+
}
|
|
38
|
+
if (e.sql === 'DELETE $fid') return [];
|
|
39
|
+
return [];
|
|
40
|
+
},
|
|
41
|
+
'local.execute': () => ({ id: new RecordId('thing', '1'), a: 1 }),
|
|
42
|
+
'ssp.ingest': () => undefined,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
expect(out.result).toBe(true);
|
|
46
|
+
expect(out.state.outbox).toEqual([expect.objectContaining({ id: 'mutation-1', type: 'create', recordId: 'thing:1' })]);
|
|
47
|
+
expect(out.state.failedCount).toBe(1);
|
|
48
|
+
expect(out.emitted).toContainEqual({ type: 'tray:changed', count: 1 });
|
|
49
|
+
expect(out.dispatched).toContainEqual({ type: 'Drain' });
|
|
50
|
+
const missing = await runPure(retryFailed(env, '_00_pending_mutations:zz'), { handlers: { 'local.query': () => [[]] } });
|
|
51
|
+
expect(missing.result).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('discard drops the row and never goes below zero', async () => {
|
|
54
|
+
const out = await runPure(discardFailed('_00_pending_mutations:m1'), {
|
|
55
|
+
state: buildState(),
|
|
56
|
+
handlers: { 'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? [[failedRow]] : []) },
|
|
57
|
+
});
|
|
58
|
+
expect(out.result).toBe(true);
|
|
59
|
+
expect(out.state.failedCount).toBe(0);
|
|
60
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'failed-mutations-changed', count: 0 } });
|
|
61
|
+
expect((await runPure(discardFailed('nope'), { handlers: { 'local.query': () => [] } })).result).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Saga } from '../kernel/saga';
|
|
2
|
+
import { fx } from '../kernel/effects';
|
|
3
|
+
import * as R from '../state/reducers';
|
|
4
|
+
import type { SagaEnv } from '../query/env';
|
|
5
|
+
import { deleteFailedRow, failedRecordId, loadFailedRows, parseFailedRow, type FailedMutationRow } from './rows';
|
|
6
|
+
import { write } from './write.saga';
|
|
7
|
+
|
|
8
|
+
/** Every rejected mutation still in the tray, oldest first. */
|
|
9
|
+
export function* listFailed(): Saga<FailedMutationRow[]> {
|
|
10
|
+
const res = (yield fx.local.query(loadFailedRows())) as unknown[];
|
|
11
|
+
const raw = Array.isArray(res?.[0]) ? (res[0] as unknown[]) : [];
|
|
12
|
+
return raw.map(parseFailedRow).filter((r): r is FailedMutationRow => r !== null);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function* loadFailed(mutationId: string): Saga<FailedMutationRow | null> {
|
|
16
|
+
const res = (yield fx.local.query('SELECT * FROM $ids', { ids: [failedRecordId(mutationId)] })) as unknown[];
|
|
17
|
+
const raw = Array.isArray(res?.[0]) ? (res[0] as unknown[])[0] : undefined;
|
|
18
|
+
return raw ? parseFailedRow(raw) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function* dropFailed(mutationId: string): Saga<void> {
|
|
22
|
+
const del = deleteFailedRow(mutationId);
|
|
23
|
+
yield fx.local.query(del.sql, del.vars);
|
|
24
|
+
const count = Math.max(0, ((yield fx.state.read((s) => s.failedCount)) as number) - 1);
|
|
25
|
+
yield fx.state.update(R.setFailedCount(count));
|
|
26
|
+
yield fx.emit({ type: 'tray:changed', count });
|
|
27
|
+
yield fx.emit({ type: 'tabs:broadcast', message: { type: 'failed-mutations-changed', count } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Re-apply a rejected mutation as a NEW optimistic write (fresh mutation id,
|
|
32
|
+
* the optimistic local change comes back, the outbox drains), then drop the
|
|
33
|
+
* tray row.
|
|
34
|
+
*/
|
|
35
|
+
export function* retryFailed(env: SagaEnv, mutationId: string): Saga<boolean> {
|
|
36
|
+
const failed = yield* loadFailed(mutationId);
|
|
37
|
+
if (!failed) return false;
|
|
38
|
+
yield* write(env, { kind: failed.mutationType, recordId: failed.recordId, data: failed.data });
|
|
39
|
+
yield* dropFailed(mutationId);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function* discardFailed(mutationId: string): Saga<boolean> {
|
|
44
|
+
const failed = yield* loadFailed(mutationId);
|
|
45
|
+
if (!failed) return false;
|
|
46
|
+
yield* dropFailed(mutationId);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { buildEntry, buildState } from '../testing/build';
|
|
5
|
+
import { defaultEnv } from '../query/env';
|
|
6
|
+
import { flushWrite, write } from './write.saga';
|
|
7
|
+
|
|
8
|
+
const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {}, b: {} } }] } as any);
|
|
9
|
+
const rid = new RecordId('thing', '1');
|
|
10
|
+
const withQuery = () => buildState([buildEntry({ def: { hash: 'q', tableName: 'thing' } })]);
|
|
11
|
+
|
|
12
|
+
describe('write', () => {
|
|
13
|
+
it('rejects unknown tables', async () => {
|
|
14
|
+
await expect(runPure(write(env, { kind: 'create', recordId: 'nope:1', data: {} }))).rejects.toThrow('Table nope not found');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('create: local tx, outbox item, circuit ingest, version 1, event, drain; dirties the table', async () => {
|
|
18
|
+
const out = await runPure(write(env, { kind: 'create', recordId: 'thing:1', data: { a: 1, junk: 2 } }), {
|
|
19
|
+
state: withQuery(),
|
|
20
|
+
handlers: {
|
|
21
|
+
'local.execute': (e: any) => {
|
|
22
|
+
expect(e.query.sql).toContain("mutationType = 'create'");
|
|
23
|
+
expect(e.vars.data).toEqual({ a: 1 });
|
|
24
|
+
return { id: rid, a: 1 };
|
|
25
|
+
},
|
|
26
|
+
'ssp.ingest': (e: any) => {
|
|
27
|
+
expect(e.records).toEqual([{ table: 'thing', op: 'CREATE', id: 'thing:1', record: { id: rid, a: 1, _00_rv: 1 } }]);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
expect(out.result).toEqual({ mutationId: 'mutation-1', record: { id: rid, a: 1 } });
|
|
32
|
+
expect(out.state.outbox).toEqual([{ id: 'mutation-1', type: 'create', recordId: 'thing:1', table: 'thing', status: 'pending', ackedAt: null, attempts: 0 }]);
|
|
33
|
+
expect(out.state.versions.get('thing:1')).toBe(1);
|
|
34
|
+
expect(out.state.dirty.has('q')).toBe(true);
|
|
35
|
+
expect(out.emitted[0]).toMatchObject({ type: 'mutation:event', event: { type: 'create', tableName: 'thing' } });
|
|
36
|
+
expect(out.dispatched).toEqual([{ type: 'Drain' }]);
|
|
37
|
+
expect(out.log.filter((e) => e.kind === 'local.query')).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('update: reads before, tx with beforeRecord, version from the returned row; delete: before feeds the circuit', async () => {
|
|
41
|
+
const upd = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 2 } }), {
|
|
42
|
+
state: withQuery(),
|
|
43
|
+
handlers: {
|
|
44
|
+
'local.query': () => [{ id: rid, a: 1, _00_rv: 4 }],
|
|
45
|
+
'local.execute': (e: any) => {
|
|
46
|
+
expect(e.vars.before).toEqual({ id: rid, a: 1, _00_rv: 4 });
|
|
47
|
+
return { target: { id: rid, a: 2, _00_rv: 5 } };
|
|
48
|
+
},
|
|
49
|
+
'ssp.ingest': (e: any) => expect(e.records[0]).toMatchObject({ op: 'UPDATE', record: { a: 2, _00_rv: 5 } }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
expect(upd.result.record).toEqual({ id: rid, a: 2, _00_rv: 5 });
|
|
53
|
+
expect(upd.state.versions.get('thing:1')).toBe(5);
|
|
54
|
+
expect(upd.emitted[0]).toMatchObject({ event: { beforeRecord: { a: 1 } } });
|
|
55
|
+
const noRow = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 2 } }), {
|
|
56
|
+
state: withQuery(),
|
|
57
|
+
handlers: { 'local.query': () => [null], 'local.execute': () => ({ target: null }), 'ssp.ingest': () => undefined },
|
|
58
|
+
});
|
|
59
|
+
expect(noRow.result.record).toBeNull();
|
|
60
|
+
expect(noRow.state.versions.get('thing:1')).toBe(1);
|
|
61
|
+
const del = await runPure(write(env, { kind: 'delete', recordId: 'thing:1' }), {
|
|
62
|
+
state: buildState([], (s) => ({ ...s, versions: new Map([['thing:1', 3]]) })),
|
|
63
|
+
handlers: {
|
|
64
|
+
'local.query': () => [{ id: rid, a: 1, _00_rv: 3 }],
|
|
65
|
+
'local.execute': () => undefined,
|
|
66
|
+
'ssp.ingest': (e: any) => expect(e.records[0]).toEqual({ table: 'thing', op: 'DELETE', id: 'thing:1', record: { id: rid, a: 1, _00_rv: 3 } }),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
expect(del.result.record).toBeNull();
|
|
70
|
+
expect(del.state.versions.has('thing:1')).toBe(false);
|
|
71
|
+
expect(del.state.outbox[0].type).toBe('delete');
|
|
72
|
+
const delNoRow = await runPure(write(env, { kind: 'delete', recordId: 'thing:1' }), {
|
|
73
|
+
state: buildState(),
|
|
74
|
+
handlers: {
|
|
75
|
+
'local.query': () => [[]],
|
|
76
|
+
'local.execute': () => undefined,
|
|
77
|
+
'ssp.ingest': (e: any) => expect(e.records[0].record).toEqual({}),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(delNoRow.emitted[0]).toMatchObject({ event: { beforeRecord: undefined } });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('a failing circuit ingest is logged, the write still stands; a follower broadcasts instead of draining', async () => {
|
|
84
|
+
const out = await runPure(write(env, { kind: 'create', recordId: 'thing:1', data: {} }), {
|
|
85
|
+
state: { ...buildState(), tabRole: 'follower' },
|
|
86
|
+
handlers: {
|
|
87
|
+
'local.execute': () => ({ id: rid }),
|
|
88
|
+
'ssp.ingest': () => {
|
|
89
|
+
throw new Error('wasm');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(out.emitted.some((e) => e.type === 'log' && e.level === 'error')).toBe(true);
|
|
94
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: 'mutation-1' } });
|
|
95
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ id: 'thing:1', op: 'CREATE' })] } });
|
|
96
|
+
expect(out.dispatched).toEqual([]);
|
|
97
|
+
expect(out.state.outbox).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('debounced update: applies locally, merges the patch per key, arms the flush timer; flush writes one outbox row', async () => {
|
|
101
|
+
const first = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: true } }), {
|
|
102
|
+
state: withQuery(),
|
|
103
|
+
handlers: {
|
|
104
|
+
'local.query': () => [{ id: rid, a: 0, _00_rv: 1 }],
|
|
105
|
+
'local.execute': () => ({ target: { id: rid, a: 1, _00_rv: 2 } }),
|
|
106
|
+
'ssp.ingest': () => undefined,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
expect(first.result).toEqual({ mutationId: '', record: { id: rid, a: 1, _00_rv: 2 } });
|
|
110
|
+
expect(first.state.outbox).toEqual([]);
|
|
111
|
+
const key = 'thing:1::a';
|
|
112
|
+
expect(first.state.pendingWrites.get(key)).toMatchObject({ data: { a: 1 }, before: { a: 0 } });
|
|
113
|
+
expect(first.timers.get(`debounce:${key}`)).toEqual({ ms: 300, event: { type: 'FlushWrite', key } });
|
|
114
|
+
expect(first.state.dirty.has('q')).toBe(true);
|
|
115
|
+
const second = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 5 }, options: { debounced: { delay: 50, key: 'recordId_x_fields' } } }), {
|
|
116
|
+
state: first.state,
|
|
117
|
+
handlers: {
|
|
118
|
+
'local.execute': () => ({ target: { id: rid, a: 5, _00_rv: 3 } }),
|
|
119
|
+
'ssp.ingest': () => {
|
|
120
|
+
throw new Error('wasm');
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
expect(second.log.filter((e) => e.kind === 'local.query')).toHaveLength(0);
|
|
125
|
+
expect(second.state.pendingWrites.get(key)).toMatchObject({ data: { a: 5 }, before: { a: 0 } });
|
|
126
|
+
expect(second.timers.get(`debounce:${key}`)!.ms).toBe(50);
|
|
127
|
+
const byRecord = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { b: 1 }, options: { debounced: { key: 'recordId' } } }), {
|
|
128
|
+
state: withQuery(),
|
|
129
|
+
handlers: { 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
130
|
+
});
|
|
131
|
+
expect(byRecord.state.pendingWrites.has('thing:1')).toBe(true);
|
|
132
|
+
expect(byRecord.result.record).toBeNull();
|
|
133
|
+
const noData = await runPure(write(env, { kind: 'update', recordId: 'thing:1', options: { debounced: true } }), {
|
|
134
|
+
state: withQuery(),
|
|
135
|
+
handlers: { 'local.query': () => undefined, 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
136
|
+
});
|
|
137
|
+
expect(noData.state.pendingWrites.has('thing:1::')).toBe(true);
|
|
138
|
+
const flushedNoBefore = await runPure(flushWrite(env, 'thing:1'), { state: byRecord.state, handlers: { 'local.execute': () => undefined } });
|
|
139
|
+
expect(flushedNoBefore.emitted[0]).toMatchObject({ event: { beforeRecord: undefined } });
|
|
140
|
+
const flushed = await runPure(flushWrite(env, key), {
|
|
141
|
+
state: second.state,
|
|
142
|
+
handlers: {
|
|
143
|
+
'local.execute': (e: any) => {
|
|
144
|
+
expect(e.query.sql).toContain("mutationType = 'update'");
|
|
145
|
+
expect(e.vars).toMatchObject({ data: { a: 5 }, before: { id: rid, a: 0, _00_rv: 1 }, table: 'thing' });
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(flushed.state.pendingWrites.size).toBe(0);
|
|
150
|
+
expect(flushed.state.outbox).toEqual([expect.objectContaining({ id: 'mutation-1', type: 'update', recordId: 'thing:1' })]);
|
|
151
|
+
expect(flushed.emitted[0]).toMatchObject({ type: 'mutation:event', event: { type: 'update', data: { a: 5 } } });
|
|
152
|
+
expect(flushed.dispatched).toEqual([{ type: 'Drain' }]);
|
|
153
|
+
const nothing = await runPure(flushWrite(env, 'missing'), { state: buildState() });
|
|
154
|
+
expect(nothing.dispatched).toEqual([]);
|
|
155
|
+
const follower = await runPure(flushWrite(env, key), { state: { ...second.state, tabRole: 'follower' }, handlers: { 'local.execute': () => undefined } });
|
|
156
|
+
expect(follower.emitted).toContainEqual(expect.objectContaining({ type: 'tabs:broadcast' }));
|
|
157
|
+
const leaderDebounced = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: true } }), {
|
|
158
|
+
state: { ...withQuery(), tabRole: 'leader' },
|
|
159
|
+
handlers: { 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
160
|
+
});
|
|
161
|
+
expect(leaderDebounced.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ op: 'UPDATE' })] } });
|
|
162
|
+
});
|
|
163
|
+
});
|