@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.290
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2027 -137
- package/dist/index.js +14564 -2472
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -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 +472 -0
- package/dist/types.d.ts +1700 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -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 +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +366 -0
- package/src/client/sp00ky-client.test.ts +271 -0
- package/src/client/sp00ky-client.ts +541 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +58 -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 +478 -0
- package/src/live/index.ts +1013 -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 +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +519 -37
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -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 +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -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 +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -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 +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -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 +199 -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 +144 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/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 +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- 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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -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 +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -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 +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -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 +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -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 +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -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 +133 -0
- package/src/testing/build.ts +63 -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 +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -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 +30 -18
- package/src/utils/utils.test.ts +2 -1
- 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 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- 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 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
/** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
|
|
14
|
+
export const PENDING_WRITES_TABLE = '_00_pending_writes';
|
|
15
|
+
export const PENDING_ROW_VERSION = 2;
|
|
16
|
+
|
|
17
|
+
/** One `_00_pending_mutations` row as this client writes it (v2). */
|
|
18
|
+
export interface PendingMutationRow {
|
|
19
|
+
id: string;
|
|
20
|
+
mutationType: MutationEventType;
|
|
21
|
+
recordId: string;
|
|
22
|
+
tableName: string;
|
|
23
|
+
data?: Record<string, unknown>;
|
|
24
|
+
beforeRecord?: Record<string, unknown> | null;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
v: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FailedMutationRow {
|
|
30
|
+
id: string;
|
|
31
|
+
mutationType: MutationEventType;
|
|
32
|
+
recordId: string;
|
|
33
|
+
tableName: string;
|
|
34
|
+
data?: Record<string, unknown>;
|
|
35
|
+
beforeRecord?: Record<string, unknown> | null;
|
|
36
|
+
error: { message: string; kind: 'application' | 'unreplayable' };
|
|
37
|
+
attempts: number;
|
|
38
|
+
createdAt: number;
|
|
39
|
+
failedAt: number;
|
|
40
|
+
revert: 'full' | 'partial' | 'none';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
|
|
45
|
+
* escaping (outbox ids contain `_`, so they come back escaped).
|
|
46
|
+
*/
|
|
47
|
+
export function parseStoredRecordId(id: string): RecordId<string> {
|
|
48
|
+
const [table, ...rest] = id.split(':');
|
|
49
|
+
let raw = rest.join(':');
|
|
50
|
+
if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
|
|
51
|
+
return new RecordId(table, raw);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stable `table:id` string for an id read from the store, escaping removed. */
|
|
55
|
+
export const storedIdString = (id: unknown): string => {
|
|
56
|
+
if (id instanceof RecordId) return `${id.table}:${String(id.id)}`;
|
|
57
|
+
const rid = parseStoredRecordId(String(id));
|
|
58
|
+
return `${rid.table}:${rid.id}`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
|
|
62
|
+
export function createdAtFromId(id: string): number | null {
|
|
63
|
+
const raw = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
|
|
64
|
+
const m = /^⟨?(\d{13})/.exec(raw);
|
|
65
|
+
return m ? Number(m[1]) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read any row shape this table ever had. Legacy rows (`v` missing) lack
|
|
70
|
+
* `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
|
|
71
|
+
* replayable and returns `null`.
|
|
72
|
+
*/
|
|
73
|
+
export function parsePendingRow(row: unknown): PendingMutationRow | null {
|
|
74
|
+
if (!row || typeof row !== 'object') return null;
|
|
75
|
+
const r = row as Record<string, unknown>;
|
|
76
|
+
const type = r.mutationType;
|
|
77
|
+
if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
|
|
78
|
+
if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
|
|
79
|
+
const id = storedIdString(r.id);
|
|
80
|
+
const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
|
|
81
|
+
const data = r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined;
|
|
82
|
+
if (type === 'create' && !data) return null;
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
mutationType: type,
|
|
86
|
+
recordId,
|
|
87
|
+
tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
|
|
88
|
+
data,
|
|
89
|
+
beforeRecord:
|
|
90
|
+
r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : undefined,
|
|
91
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : (createdAtFromId(id) ?? 0),
|
|
92
|
+
v: typeof r.v === 'number' ? r.v : 1,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Re-type a stored row's payload from the schema before it is pushed. The
|
|
98
|
+
* store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
|
|
99
|
+
* datetime as a string; the server coerces neither. Unknown keys stay as they
|
|
100
|
+
* are, and a payload the schema rejects is sent raw so the server's answer,
|
|
101
|
+
* not a local throw, decides its fate.
|
|
102
|
+
*/
|
|
103
|
+
export function hydrateRowData(row: PendingMutationRow, columns: Record<string, unknown> | null): PendingMutationRow {
|
|
104
|
+
if (!row.data || !columns) return row;
|
|
105
|
+
try {
|
|
106
|
+
return { ...row, data: parseQueryParams(columns as never, row.data) };
|
|
107
|
+
} catch {
|
|
108
|
+
return row;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
|
|
113
|
+
id: row.id,
|
|
114
|
+
type: row.mutationType,
|
|
115
|
+
recordId: row.recordId,
|
|
116
|
+
table: row.tableName,
|
|
117
|
+
status: 'pending',
|
|
118
|
+
ackedAt: null,
|
|
119
|
+
attempts: 0,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One `_00_pending_writes` row: the merged patch of a debounced update that
|
|
124
|
+
* has not reached the outbox yet. `flushBy` is when the writing tab's timer
|
|
125
|
+
* is due to move it there.
|
|
126
|
+
*/
|
|
127
|
+
export interface PendingWriteRow {
|
|
128
|
+
id: string;
|
|
129
|
+
recordId: string;
|
|
130
|
+
tableName: string;
|
|
131
|
+
data: Record<string, unknown>;
|
|
132
|
+
beforeRecord: Record<string, unknown> | null;
|
|
133
|
+
flushBy: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `_00_pending_writes` id for a minted mutation id (same suffix). */
|
|
137
|
+
export const pendingWriteId = (mutationId: string): string =>
|
|
138
|
+
`${PENDING_WRITES_TABLE}:${mutationId.includes(':') ? mutationId.slice(mutationId.indexOf(':') + 1) : mutationId}`;
|
|
139
|
+
|
|
140
|
+
export function parsePendingWriteRow(row: unknown): PendingWriteRow | null {
|
|
141
|
+
if (!row || typeof row !== 'object') return null;
|
|
142
|
+
const r = row as Record<string, unknown>;
|
|
143
|
+
if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
|
|
144
|
+
if (!r.data || typeof r.data !== 'object') return null;
|
|
145
|
+
const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
|
|
146
|
+
return {
|
|
147
|
+
id: storedIdString(r.id),
|
|
148
|
+
recordId,
|
|
149
|
+
tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
|
|
150
|
+
data: r.data as Record<string, unknown>,
|
|
151
|
+
beforeRecord: r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
|
|
152
|
+
flushBy: typeof r.flushBy === 'number' ? r.flushBy : 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const loadPendingRows = (): string => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
|
|
157
|
+
export const loadPendingWriteRows = (): string => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
|
|
158
|
+
export const loadFailedRows = (): string => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
|
|
159
|
+
|
|
160
|
+
// ---- local write transactions ------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export interface LocalTx {
|
|
163
|
+
query: SealedQuery<unknown>;
|
|
164
|
+
vars: Vars;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pendingInsert = (type: MutationEventType, withData: boolean, withBefore: boolean): string => {
|
|
168
|
+
const fields = [
|
|
169
|
+
`mutationType = '${type}'`,
|
|
170
|
+
'recordId = $id',
|
|
171
|
+
'tableName = $table',
|
|
172
|
+
'createdAt = $createdAt',
|
|
173
|
+
`v = ${PENDING_ROW_VERSION}`,
|
|
174
|
+
];
|
|
175
|
+
if (withData) fields.push('data = $data');
|
|
176
|
+
if (withBefore) fields.push('beforeRecord = $before');
|
|
177
|
+
return `CREATE ONLY $mid SET ${fields.join(', ')}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export interface WritePlanInput {
|
|
181
|
+
recordId: RecordId<string>;
|
|
182
|
+
mutationId: RecordId<string>;
|
|
183
|
+
table: string;
|
|
184
|
+
data?: Record<string, unknown>;
|
|
185
|
+
before?: Record<string, unknown> | null;
|
|
186
|
+
now: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
|
|
190
|
+
export function planCreateTx(input: WritePlanInput): LocalTx {
|
|
191
|
+
const data = input.data ?? {};
|
|
192
|
+
const keys = Object.keys(data).map((key) => ({ key, variable: `data_${key}` }));
|
|
193
|
+
const vars: Vars = { id: input.recordId, mid: input.mutationId, table: input.table, createdAt: input.now, data };
|
|
194
|
+
for (const { key, variable } of keys) vars[variable] = data[key];
|
|
195
|
+
return {
|
|
196
|
+
query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys), pendingInsert('create', true, false)]), {
|
|
197
|
+
resultIndex: 0,
|
|
198
|
+
}),
|
|
199
|
+
vars,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
|
|
204
|
+
export function planUpdateTx(input: WritePlanInput): LocalTx {
|
|
205
|
+
return {
|
|
206
|
+
query: surql.seal<unknown>(
|
|
207
|
+
surql.tx([
|
|
208
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
209
|
+
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
210
|
+
pendingInsert('update', true, true),
|
|
211
|
+
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
212
|
+
])
|
|
213
|
+
),
|
|
214
|
+
vars: {
|
|
215
|
+
id: input.recordId,
|
|
216
|
+
mid: input.mutationId,
|
|
217
|
+
table: input.table,
|
|
218
|
+
createdAt: input.now,
|
|
219
|
+
data: input.data ?? {},
|
|
220
|
+
before: input.before ?? null,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** DELETE the row and write the outbox entry (with `beforeRecord`). */
|
|
226
|
+
export function planDeleteTx(input: WritePlanInput): LocalTx {
|
|
227
|
+
return {
|
|
228
|
+
query: surql.seal<unknown>(surql.tx([surql.delete('id'), pendingInsert('delete', false, true)])),
|
|
229
|
+
vars: {
|
|
230
|
+
id: input.recordId,
|
|
231
|
+
mid: input.mutationId,
|
|
232
|
+
table: input.table,
|
|
233
|
+
createdAt: input.now,
|
|
234
|
+
before: input.before ?? null,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
|
|
241
|
+
* With a `mirror`, the merged patch so far is written to `_00_pending_writes`
|
|
242
|
+
* in the same transaction, so the row never changes without a durable copy of
|
|
243
|
+
* what still has to reach the server.
|
|
244
|
+
*/
|
|
245
|
+
export function planLocalOnlyUpdateTx(input: {
|
|
246
|
+
recordId: RecordId<string>;
|
|
247
|
+
data: Record<string, unknown>;
|
|
248
|
+
mirror?: { id: RecordId<string>; row: Omit<PendingWriteRow, 'id' | 'recordId'> & { recordId: RecordId<string> } };
|
|
249
|
+
}): LocalTx {
|
|
250
|
+
const statements = [surql.updateSet('id', [{ statement: '_00_rv += 1' }]), surql.let('updated', surql.updateMerge('id', 'data'))];
|
|
251
|
+
if (input.mirror) statements.push(surql.upsert('wid', 'wrow'));
|
|
252
|
+
statements.push(surql.returnObject([{ key: 'target', variable: 'updated' }]));
|
|
253
|
+
return {
|
|
254
|
+
query: surql.seal<unknown>(surql.tx(statements)),
|
|
255
|
+
vars: input.mirror ? { id: input.recordId, data: input.data, wid: input.mirror.id, wrow: input.mirror.row } : { id: input.recordId, data: input.data },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The outbox row of a flushed debounced update. With `mirrorId` the
|
|
261
|
+
* `_00_pending_writes` row goes in the same transaction: the patch moves
|
|
262
|
+
* from one table to the other, it is never in both or in neither.
|
|
263
|
+
*/
|
|
264
|
+
export function planDeferredOutboxRowTx(input: WritePlanInput & { mirrorId?: RecordId<string> }): LocalTx {
|
|
265
|
+
const statements = [pendingInsert('update', true, true)];
|
|
266
|
+
if (input.mirrorId) statements.push(surql.delete('wid'));
|
|
267
|
+
return {
|
|
268
|
+
query: surql.seal<unknown>(surql.tx(statements)),
|
|
269
|
+
vars: {
|
|
270
|
+
id: input.recordId,
|
|
271
|
+
mid: input.mutationId,
|
|
272
|
+
table: input.table,
|
|
273
|
+
createdAt: input.now,
|
|
274
|
+
data: input.data ?? {},
|
|
275
|
+
before: input.before ?? null,
|
|
276
|
+
...(input.mirrorId ? { wid: input.mirrorId } : {}),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- remote push -------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
export interface RemoteBatch {
|
|
284
|
+
sql: string;
|
|
285
|
+
vars: Vars;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Many outbox rows as ONE multi-statement request. Statement i answers row i.
|
|
290
|
+
* Each statement is its own transaction on the server, as before.
|
|
291
|
+
*/
|
|
292
|
+
export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>): RemoteBatch {
|
|
293
|
+
const vars: Vars = {};
|
|
294
|
+
const stmts = rows.map((row, i) => {
|
|
295
|
+
const rid = parseRecordIdString(row.recordId);
|
|
296
|
+
vars[`id${i}`] = rid;
|
|
297
|
+
switch (row.mutationType) {
|
|
298
|
+
case 'create': {
|
|
299
|
+
const data = row.data ?? {};
|
|
300
|
+
const sets = Object.keys(data).map((key) => {
|
|
301
|
+
vars[`d${i}_${key}`] = data[key];
|
|
302
|
+
return `${key} = $d${i}_${key}`;
|
|
303
|
+
});
|
|
304
|
+
return `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
|
|
305
|
+
}
|
|
306
|
+
case 'update':
|
|
307
|
+
vars[`data${i}`] = row.data ?? {};
|
|
308
|
+
return `UPDATE $id${i} MERGE $data${i}`;
|
|
309
|
+
case 'delete':
|
|
310
|
+
return `DELETE $id${i}`;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
return { sql: stmts.join(';\n'), vars };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- rollback ----------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export interface RevertPlan {
|
|
319
|
+
tx: LocalTx | null;
|
|
320
|
+
circuit: IngestRecord;
|
|
321
|
+
revert: FailedMutationRow['revert'];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* How to undo a rejected mutation locally. A create is deleted; an update or
|
|
326
|
+
* delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
|
|
327
|
+
* comes back). Without a `beforeRecord` nothing can be restored.
|
|
328
|
+
*/
|
|
329
|
+
export function planRevert(row: PendingMutationRow, before: Record<string, unknown> | null): RevertPlan {
|
|
330
|
+
const rid = parseRecordIdString(row.recordId);
|
|
331
|
+
if (row.mutationType === 'create') {
|
|
332
|
+
return {
|
|
333
|
+
tx: { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: rid } },
|
|
334
|
+
circuit: { table: row.tableName, op: 'DELETE', id: row.recordId, record: row.data ?? {} },
|
|
335
|
+
revert: 'full',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (!before) {
|
|
339
|
+
return {
|
|
340
|
+
tx: null,
|
|
341
|
+
circuit: { table: row.tableName, op: 'UPDATE', id: row.recordId, record: {} },
|
|
342
|
+
revert: 'partial',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const { id: _id, ...content } = before;
|
|
346
|
+
return {
|
|
347
|
+
tx: { query: surql.seal<unknown>(surql.tx([surql.upsert('id', 'content')])), vars: { id: rid, content } },
|
|
348
|
+
circuit: {
|
|
349
|
+
table: row.tableName,
|
|
350
|
+
op: row.mutationType === 'delete' ? 'CREATE' : 'UPDATE',
|
|
351
|
+
id: row.recordId,
|
|
352
|
+
record: { ...before, id: rid },
|
|
353
|
+
},
|
|
354
|
+
revert: 'full',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export const failedRecordId = (mutationId: string): RecordId<string> =>
|
|
359
|
+
new RecordId(FAILED_TABLE, mutationId.slice(mutationId.indexOf(':') + 1));
|
|
360
|
+
|
|
361
|
+
export function buildFailedRow(
|
|
362
|
+
row: PendingMutationRow,
|
|
363
|
+
error: { message: string; kind: 'application' | 'unreplayable' },
|
|
364
|
+
before: Record<string, unknown> | null,
|
|
365
|
+
attempts: number,
|
|
366
|
+
now: number,
|
|
367
|
+
revert: FailedMutationRow['revert']
|
|
368
|
+
): FailedMutationRow {
|
|
369
|
+
return {
|
|
370
|
+
id: row.id,
|
|
371
|
+
mutationType: row.mutationType,
|
|
372
|
+
recordId: row.recordId,
|
|
373
|
+
tableName: row.tableName,
|
|
374
|
+
data: row.data,
|
|
375
|
+
beforeRecord: before,
|
|
376
|
+
error,
|
|
377
|
+
attempts,
|
|
378
|
+
createdAt: row.createdAt,
|
|
379
|
+
failedAt: now,
|
|
380
|
+
revert,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** ONE transaction: write the tray row, delete the pending row. */
|
|
385
|
+
export function moveToFailedTx(failed: FailedMutationRow): LocalTx {
|
|
386
|
+
const { id: _id, ...content } = failed;
|
|
387
|
+
return {
|
|
388
|
+
query: surql.seal<unknown>(surql.tx([surql.create('fid', 'failed'), surql.delete('mid')])),
|
|
389
|
+
vars: { fid: failedRecordId(failed.id), failed: content, mid: parseStoredRecordId(failed.id) },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export const deletePendingRow = (mutationId: string): { sql: string; vars: Vars } => ({
|
|
394
|
+
sql: 'DELETE $mid',
|
|
395
|
+
vars: { mid: parseStoredRecordId(mutationId) },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export const deleteFailedRow = (mutationId: string): { sql: string; vars: Vars } => ({
|
|
399
|
+
sql: 'DELETE $fid',
|
|
400
|
+
vars: { fid: failedRecordId(mutationId) },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
export function parseFailedRow(row: unknown): FailedMutationRow | null {
|
|
404
|
+
if (!row || typeof row !== 'object') return null;
|
|
405
|
+
const r = row as Record<string, unknown>;
|
|
406
|
+
const type = r.mutationType;
|
|
407
|
+
if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
|
|
408
|
+
if (typeof r.recordId !== 'string') return null;
|
|
409
|
+
const err = (r.error ?? {}) as Record<string, unknown>;
|
|
410
|
+
return {
|
|
411
|
+
id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
|
|
412
|
+
mutationType: type,
|
|
413
|
+
recordId: r.recordId,
|
|
414
|
+
tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(r.recordId),
|
|
415
|
+
data: r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined,
|
|
416
|
+
beforeRecord:
|
|
417
|
+
r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
|
|
418
|
+
error: {
|
|
419
|
+
message: typeof err.message === 'string' ? err.message : 'unknown',
|
|
420
|
+
kind: err.kind === 'unreplayable' ? 'unreplayable' : 'application',
|
|
421
|
+
},
|
|
422
|
+
attempts: typeof r.attempts === 'number' ? r.attempts : 0,
|
|
423
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
424
|
+
failedAt: typeof r.failedAt === 'number' ? r.failedAt : 0,
|
|
425
|
+
revert: r.revert === 'partial' || r.revert === 'none' ? r.revert : 'full',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
@@ -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
|
+
}
|