@syncular/client 0.12.0 → 0.13.0
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/README.md +8 -0
- package/dist/client.js +97 -36
- package/dist/outbox.d.ts +10 -1
- package/dist/outbox.js +46 -1
- package/dist/schema.js +7 -0
- package/package.json +3 -3
- package/src/client.ts +143 -37
- package/src/outbox.ts +80 -0
- package/src/schema.ts +7 -0
package/README.md
CHANGED
|
@@ -123,6 +123,14 @@ that rolled back with the terminating operation. It stays in the protected
|
|
|
123
123
|
client database and is never added to the wire protocol, preferences, or
|
|
124
124
|
telemetry; successful and historical outcomes may omit it.
|
|
125
125
|
|
|
126
|
+
Rejected optimistic state is removed immediately, even when a validator emits
|
|
127
|
+
no server row in the pull half. The TypeScript client stores protected,
|
|
128
|
+
restart-safe before-images beside each pending outbox commit, restores the last
|
|
129
|
+
confirmed rows (including rejected deletes and atomic siblings), and then
|
|
130
|
+
replays later pending edits. These before-images are internal rollback state:
|
|
131
|
+
they are never encoded on the wire or exposed through pending commits, outcome
|
|
132
|
+
envelopes, diagnostics, preferences, or telemetry.
|
|
133
|
+
|
|
126
134
|
Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
|
|
127
135
|
updates. The wire still carries a full row, but the durable local operation
|
|
128
136
|
records a sorted `changedFields` list so conflict and rejection UI knows which
|
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@ import { registerDevtools } from './devtools.js';
|
|
|
14
14
|
import { ClientSyncError } from './errors.js';
|
|
15
15
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
16
16
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
17
|
-
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
|
|
17
|
+
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
18
18
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
19
19
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
20
20
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
@@ -1004,7 +1004,7 @@ export class SyncClient {
|
|
|
1004
1004
|
});
|
|
1005
1005
|
this.#applyBatch((batch) => {
|
|
1006
1006
|
this.#db.transaction(() => {
|
|
1007
|
-
appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
|
|
1007
|
+
appendOutboxCommit(this.#db, clientCommitId, operations, this.#now(), this.#captureBeforeImages(operations));
|
|
1008
1008
|
this.#applyOperationsLocally(operations, batch);
|
|
1009
1009
|
batch.status();
|
|
1010
1010
|
});
|
|
@@ -1123,21 +1123,7 @@ export class SyncClient {
|
|
|
1123
1123
|
*/
|
|
1124
1124
|
#dropIncompatibleCommit(commit, message) {
|
|
1125
1125
|
this.#applyBatch((batch) => {
|
|
1126
|
-
|
|
1127
|
-
for (const operation of commit.operations) {
|
|
1128
|
-
if (operation.op !== 'upsert')
|
|
1129
|
-
continue;
|
|
1130
|
-
const table = this.#schema.tables.get(operation.table);
|
|
1131
|
-
if (table === undefined)
|
|
1132
|
-
continue;
|
|
1133
|
-
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
1134
|
-
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1135
|
-
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1136
|
-
batch.table(table.name);
|
|
1137
|
-
}
|
|
1138
|
-
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1126
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1141
1127
|
const rejection = {
|
|
1142
1128
|
clientCommitId: commit.clientCommitId,
|
|
1143
1129
|
opIndex: 0,
|
|
@@ -1811,26 +1797,11 @@ export class SyncClient {
|
|
|
1811
1797
|
});
|
|
1812
1798
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
1813
1799
|
batch.outcomes();
|
|
1814
|
-
// §7.2:
|
|
1815
|
-
//
|
|
1816
|
-
//
|
|
1817
|
-
// half (the conflict record carries the server row for the app).
|
|
1800
|
+
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
1801
|
+
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
1802
|
+
// later pending overlays are then replayed with rebased before-images.
|
|
1818
1803
|
this.#db.transaction(() => {
|
|
1819
|
-
|
|
1820
|
-
for (const operation of commit.operations) {
|
|
1821
|
-
if (operation.op !== 'upsert')
|
|
1822
|
-
continue;
|
|
1823
|
-
const table = this.#schema.tables.get(operation.table);
|
|
1824
|
-
if (table === undefined)
|
|
1825
|
-
continue;
|
|
1826
|
-
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
1827
|
-
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1828
|
-
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1829
|
-
batch.table(table.name);
|
|
1830
|
-
}
|
|
1831
|
-
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1804
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1834
1805
|
});
|
|
1835
1806
|
batch.status();
|
|
1836
1807
|
summary.rejected.push(frame.clientCommitId);
|
|
@@ -2127,6 +2098,96 @@ export class SyncClient {
|
|
|
2127
2098
|
return false;
|
|
2128
2099
|
}
|
|
2129
2100
|
// -- optimistic state ----------------------------------------------------------
|
|
2101
|
+
#captureBeforeImage(operation, opIndex) {
|
|
2102
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2103
|
+
if (table === undefined)
|
|
2104
|
+
return { opIndex, existed: false };
|
|
2105
|
+
const columns = [
|
|
2106
|
+
...table.columns.map((column) => quoteIdent(column.name)),
|
|
2107
|
+
quoteIdent(SYNC_VERSION_COLUMN),
|
|
2108
|
+
];
|
|
2109
|
+
const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
2110
|
+
if (row === undefined)
|
|
2111
|
+
return { opIndex, existed: false };
|
|
2112
|
+
const values = {};
|
|
2113
|
+
for (const column of table.columns) {
|
|
2114
|
+
values[column.name] = rowValueToJson(fromSqlValue(column, row[column.name] ?? null));
|
|
2115
|
+
}
|
|
2116
|
+
const syncVersion = row[SYNC_VERSION_COLUMN];
|
|
2117
|
+
if (typeof syncVersion !== 'number') {
|
|
2118
|
+
throw new ClientSyncError('sync.local_corrupt', `local row ${operation.table}/${operation.rowId} has no sync version`);
|
|
2119
|
+
}
|
|
2120
|
+
return { opIndex, existed: true, syncVersion, values };
|
|
2121
|
+
}
|
|
2122
|
+
#captureBeforeImages(operations, onlyKeys) {
|
|
2123
|
+
return operations.flatMap((operation, opIndex) => onlyKeys && !onlyKeys.has(`${operation.table}\u0000${operation.rowId}`)
|
|
2124
|
+
? []
|
|
2125
|
+
: [this.#captureBeforeImage(operation, opIndex)]);
|
|
2126
|
+
}
|
|
2127
|
+
#restoreBeforeImage(operation, image, batch) {
|
|
2128
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2129
|
+
if (table === undefined)
|
|
2130
|
+
return;
|
|
2131
|
+
batch.table(table.name);
|
|
2132
|
+
if (!image.existed) {
|
|
2133
|
+
deleteLocalRow(this.#db, table, operation.rowId);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (image.values === undefined || image.syncVersion === undefined) {
|
|
2137
|
+
throw new ClientSyncError('sync.local_corrupt', `rollback image for ${operation.table}/${operation.rowId} is incomplete`);
|
|
2138
|
+
}
|
|
2139
|
+
const values = table.columns.map((column) => jsonToRowValue(image.values?.[column.name] ?? null));
|
|
2140
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
2141
|
+
}
|
|
2142
|
+
#legacyUndoOptimisticRows(commit, batch) {
|
|
2143
|
+
for (const operation of commit.operations) {
|
|
2144
|
+
if (operation.op !== 'upsert')
|
|
2145
|
+
continue;
|
|
2146
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2147
|
+
if (table === undefined)
|
|
2148
|
+
continue;
|
|
2149
|
+
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
|
|
2150
|
+
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
2151
|
+
batch.table(table.name);
|
|
2152
|
+
deleteLocalRow(this.#db, table, operation.rowId);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
#rollbackFailedCommit(commit, batch) {
|
|
2157
|
+
const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
|
|
2158
|
+
const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
|
|
2159
|
+
const complete = commit.operations.every((_, index) => imageByIndex.has(index));
|
|
2160
|
+
const affectedKeys = new Set(commit.operations.map((operation) => `${operation.table}\u0000${operation.rowId}`));
|
|
2161
|
+
if (complete) {
|
|
2162
|
+
const restored = new Set();
|
|
2163
|
+
commit.operations.forEach((operation, index) => {
|
|
2164
|
+
const key = `${operation.table}\u0000${operation.rowId}`;
|
|
2165
|
+
if (restored.has(key))
|
|
2166
|
+
return;
|
|
2167
|
+
const image = imageByIndex.get(index);
|
|
2168
|
+
if (image)
|
|
2169
|
+
this.#restoreBeforeImage(operation, image, batch);
|
|
2170
|
+
restored.add(key);
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
else {
|
|
2174
|
+
// Pending commits written by older clients have no before-images.
|
|
2175
|
+
// Preserve the old fail-closed behavior rather than guessing a base.
|
|
2176
|
+
this.#legacyUndoOptimisticRows(commit, batch);
|
|
2177
|
+
}
|
|
2178
|
+
deleteOutboxCommit(this.#db, commit.clientCommitId);
|
|
2179
|
+
if (!complete)
|
|
2180
|
+
return;
|
|
2181
|
+
for (const later of listOutbox(this.#db)) {
|
|
2182
|
+
if (later.seq <= commit.seq)
|
|
2183
|
+
continue;
|
|
2184
|
+
const affectedOperations = later.operations.filter((operation) => affectedKeys.has(`${operation.table}\u0000${operation.rowId}`));
|
|
2185
|
+
if (affectedOperations.length === 0)
|
|
2186
|
+
continue;
|
|
2187
|
+
replaceOutboxBeforeImages(this.#db, later.clientCommitId, this.#captureBeforeImages(later.operations, affectedKeys));
|
|
2188
|
+
this.#applyOperationsLocally(affectedOperations, batch);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2130
2191
|
#applyOperationsLocally(operations, batch) {
|
|
2131
2192
|
for (const op of operations) {
|
|
2132
2193
|
const table = this.#table(op.table);
|
package/dist/outbox.d.ts
CHANGED
|
@@ -30,10 +30,19 @@ export interface OutboxCommit {
|
|
|
30
30
|
readonly createdAtMs: number;
|
|
31
31
|
readonly operations: readonly OutboxOperation[];
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
/** Protected local rollback image; never encoded or exposed in outcomes. */
|
|
34
|
+
export interface OutboxBeforeImage {
|
|
35
|
+
readonly opIndex: number;
|
|
36
|
+
readonly existed: boolean;
|
|
37
|
+
readonly syncVersion?: number;
|
|
38
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
39
|
+
}
|
|
40
|
+
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
34
41
|
/** Pending commits in FIFO creation order (§7.1). */
|
|
35
42
|
export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
|
|
36
43
|
export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
|
|
44
|
+
export declare function listOutboxBeforeImages(db: ClientDatabase, clientCommitId: string): OutboxBeforeImage[];
|
|
45
|
+
export declare function replaceOutboxBeforeImages(db: ClientDatabase, clientCommitId: string, replacements: readonly OutboxBeforeImage[]): void;
|
|
37
46
|
/**
|
|
38
47
|
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
39
48
|
* schema no longer has. The value has nowhere to go and there is no
|
package/dist/outbox.js
CHANGED
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
import { encodeRow, } from '@syncular/core';
|
|
9
9
|
import { ClientSyncError } from './errors.js';
|
|
10
10
|
import { jsonToRowValue, } from './schema.js';
|
|
11
|
-
export function appendOutboxCommit(db, clientCommitId, operations, nowMs) {
|
|
11
|
+
export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
|
|
12
12
|
if (operations.length === 0) {
|
|
13
13
|
throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
|
|
14
14
|
}
|
|
15
15
|
db.exec(`INSERT INTO _syncular_outbox(client_commit_id, created_at_ms, operations)
|
|
16
16
|
VALUES (?, ?, ?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
17
|
+
for (const image of beforeImages) {
|
|
18
|
+
db.exec(`INSERT INTO _syncular_outbox_before_images(
|
|
19
|
+
client_commit_id, op_index, existed, sync_version, values_json
|
|
20
|
+
) VALUES (?, ?, ?, ?, ?)`, [
|
|
21
|
+
clientCommitId,
|
|
22
|
+
image.opIndex,
|
|
23
|
+
image.existed ? 1 : 0,
|
|
24
|
+
image.syncVersion ?? null,
|
|
25
|
+
image.values === undefined ? null : JSON.stringify(image.values),
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
17
28
|
}
|
|
18
29
|
/** Pending commits in FIFO creation order (§7.1). */
|
|
19
30
|
export function listOutbox(db) {
|
|
@@ -28,10 +39,44 @@ export function listOutbox(db) {
|
|
|
28
39
|
}));
|
|
29
40
|
}
|
|
30
41
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
42
|
+
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
|
|
31
43
|
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
|
|
32
44
|
clientCommitId,
|
|
33
45
|
]);
|
|
34
46
|
}
|
|
47
|
+
export function listOutboxBeforeImages(db, clientCommitId) {
|
|
48
|
+
return db
|
|
49
|
+
.query(`SELECT op_index, existed, sync_version, values_json
|
|
50
|
+
FROM _syncular_outbox_before_images
|
|
51
|
+
WHERE client_commit_id = ? ORDER BY op_index`, [clientCommitId])
|
|
52
|
+
.map((row) => ({
|
|
53
|
+
opIndex: row.op_index,
|
|
54
|
+
existed: row.existed === 1,
|
|
55
|
+
...(typeof row.sync_version === 'number'
|
|
56
|
+
? { syncVersion: row.sync_version }
|
|
57
|
+
: {}),
|
|
58
|
+
...(typeof row.values_json === 'string'
|
|
59
|
+
? {
|
|
60
|
+
values: JSON.parse(row.values_json),
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
|
|
66
|
+
for (const image of replacements) {
|
|
67
|
+
db.exec(`DELETE FROM _syncular_outbox_before_images
|
|
68
|
+
WHERE client_commit_id = ? AND op_index = ?`, [clientCommitId, image.opIndex]);
|
|
69
|
+
db.exec(`INSERT INTO _syncular_outbox_before_images(
|
|
70
|
+
client_commit_id, op_index, existed, sync_version, values_json
|
|
71
|
+
) VALUES (?, ?, ?, ?, ?)`, [
|
|
72
|
+
clientCommitId,
|
|
73
|
+
image.opIndex,
|
|
74
|
+
image.existed ? 1 : 0,
|
|
75
|
+
image.syncVersion ?? null,
|
|
76
|
+
image.values === undefined ? null : JSON.stringify(image.values),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
35
80
|
/**
|
|
36
81
|
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
37
82
|
* schema no longer has. The value has nowhere to go and there is no
|
package/dist/schema.js
CHANGED
|
@@ -184,6 +184,13 @@ export function ensureLocalSchema(db, schema) {
|
|
|
184
184
|
client_commit_id TEXT NOT NULL UNIQUE,
|
|
185
185
|
created_at_ms INTEGER NOT NULL,
|
|
186
186
|
operations TEXT NOT NULL)`);
|
|
187
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
|
|
188
|
+
client_commit_id TEXT NOT NULL,
|
|
189
|
+
op_index INTEGER NOT NULL,
|
|
190
|
+
existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
|
|
191
|
+
sync_version INTEGER,
|
|
192
|
+
values_json TEXT,
|
|
193
|
+
PRIMARY KEY(client_commit_id, op_index))`);
|
|
187
194
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
|
|
188
195
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
196
|
client_commit_id TEXT NOT NULL UNIQUE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.
|
|
84
|
+
"@syncular/core": "0.13.0"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.13.0",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -86,9 +86,12 @@ import {
|
|
|
86
86
|
dropOutboxCommitsInScope,
|
|
87
87
|
encodeOutboxCommit,
|
|
88
88
|
listOutbox,
|
|
89
|
+
listOutboxBeforeImages,
|
|
90
|
+
type OutboxBeforeImage,
|
|
89
91
|
type OutboxCommit,
|
|
90
92
|
OutboxEncodeError,
|
|
91
93
|
type OutboxOperation,
|
|
94
|
+
replaceOutboxBeforeImages,
|
|
92
95
|
} from './outbox';
|
|
93
96
|
import {
|
|
94
97
|
activeFailureRecords,
|
|
@@ -1551,7 +1554,13 @@ export class SyncClient {
|
|
|
1551
1554
|
});
|
|
1552
1555
|
this.#applyBatch((batch) => {
|
|
1553
1556
|
this.#db.transaction(() => {
|
|
1554
|
-
appendOutboxCommit(
|
|
1557
|
+
appendOutboxCommit(
|
|
1558
|
+
this.#db,
|
|
1559
|
+
clientCommitId,
|
|
1560
|
+
operations,
|
|
1561
|
+
this.#now(),
|
|
1562
|
+
this.#captureBeforeImages(operations),
|
|
1563
|
+
);
|
|
1555
1564
|
this.#applyOperationsLocally(operations, batch);
|
|
1556
1565
|
batch.status();
|
|
1557
1566
|
});
|
|
@@ -1705,22 +1714,7 @@ export class SyncClient {
|
|
|
1705
1714
|
*/
|
|
1706
1715
|
#dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
|
|
1707
1716
|
this.#applyBatch((batch) => {
|
|
1708
|
-
|
|
1709
|
-
for (const operation of commit.operations) {
|
|
1710
|
-
if (operation.op !== 'upsert') continue;
|
|
1711
|
-
const table = this.#schema.tables.get(operation.table);
|
|
1712
|
-
if (table === undefined) continue;
|
|
1713
|
-
const row = this.#db.query(
|
|
1714
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
1715
|
-
[operation.rowId],
|
|
1716
|
-
)[0];
|
|
1717
|
-
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
1718
|
-
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
1719
|
-
batch.table(table.name);
|
|
1720
|
-
}
|
|
1721
|
-
deleteLocalRow(this.#db, table, operation.rowId);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1717
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1724
1718
|
const rejection: RejectionRecord = {
|
|
1725
1719
|
clientCommitId: commit.clientCommitId,
|
|
1726
1720
|
opIndex: 0,
|
|
@@ -2548,27 +2542,11 @@ export class SyncClient {
|
|
|
2548
2542
|
});
|
|
2549
2543
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2550
2544
|
batch.outcomes();
|
|
2551
|
-
// §7.2:
|
|
2552
|
-
//
|
|
2553
|
-
//
|
|
2554
|
-
// half (the conflict record carries the server row for the app).
|
|
2545
|
+
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2546
|
+
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
2547
|
+
// later pending overlays are then replayed with rebased before-images.
|
|
2555
2548
|
this.#db.transaction(() => {
|
|
2556
|
-
|
|
2557
|
-
for (const operation of commit.operations) {
|
|
2558
|
-
if (operation.op !== 'upsert') continue;
|
|
2559
|
-
const table = this.#schema.tables.get(operation.table);
|
|
2560
|
-
if (table === undefined) continue;
|
|
2561
|
-
const row = this.#db.query(
|
|
2562
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2563
|
-
[operation.rowId],
|
|
2564
|
-
)[0];
|
|
2565
|
-
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
2566
|
-
if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
|
|
2567
|
-
batch.table(table.name);
|
|
2568
|
-
}
|
|
2569
|
-
deleteLocalRow(this.#db, table, operation.rowId);
|
|
2570
|
-
}
|
|
2571
|
-
}
|
|
2549
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
2572
2550
|
});
|
|
2573
2551
|
batch.status();
|
|
2574
2552
|
summary.rejected.push(frame.clientCommitId);
|
|
@@ -2945,6 +2923,134 @@ export class SyncClient {
|
|
|
2945
2923
|
|
|
2946
2924
|
// -- optimistic state ----------------------------------------------------------
|
|
2947
2925
|
|
|
2926
|
+
#captureBeforeImage(
|
|
2927
|
+
operation: OutboxOperation,
|
|
2928
|
+
opIndex: number,
|
|
2929
|
+
): OutboxBeforeImage {
|
|
2930
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2931
|
+
if (table === undefined) return { opIndex, existed: false };
|
|
2932
|
+
const columns = [
|
|
2933
|
+
...table.columns.map((column) => quoteIdent(column.name)),
|
|
2934
|
+
quoteIdent(SYNC_VERSION_COLUMN),
|
|
2935
|
+
];
|
|
2936
|
+
const row = this.#db.query(
|
|
2937
|
+
`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2938
|
+
[operation.rowId],
|
|
2939
|
+
)[0];
|
|
2940
|
+
if (row === undefined) return { opIndex, existed: false };
|
|
2941
|
+
const values: Record<string, ReturnType<typeof rowValueToJson>> = {};
|
|
2942
|
+
for (const column of table.columns) {
|
|
2943
|
+
values[column.name] = rowValueToJson(
|
|
2944
|
+
fromSqlValue(column, row[column.name] ?? null),
|
|
2945
|
+
);
|
|
2946
|
+
}
|
|
2947
|
+
const syncVersion = row[SYNC_VERSION_COLUMN];
|
|
2948
|
+
if (typeof syncVersion !== 'number') {
|
|
2949
|
+
throw new ClientSyncError(
|
|
2950
|
+
'sync.local_corrupt',
|
|
2951
|
+
`local row ${operation.table}/${operation.rowId} has no sync version`,
|
|
2952
|
+
);
|
|
2953
|
+
}
|
|
2954
|
+
return { opIndex, existed: true, syncVersion, values };
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
#captureBeforeImages(
|
|
2958
|
+
operations: readonly OutboxOperation[],
|
|
2959
|
+
onlyKeys?: ReadonlySet<string>,
|
|
2960
|
+
): OutboxBeforeImage[] {
|
|
2961
|
+
return operations.flatMap((operation, opIndex) =>
|
|
2962
|
+
onlyKeys && !onlyKeys.has(`${operation.table}\u0000${operation.rowId}`)
|
|
2963
|
+
? []
|
|
2964
|
+
: [this.#captureBeforeImage(operation, opIndex)],
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
#restoreBeforeImage(
|
|
2969
|
+
operation: OutboxOperation,
|
|
2970
|
+
image: OutboxBeforeImage,
|
|
2971
|
+
batch: ChangeAccumulator,
|
|
2972
|
+
): void {
|
|
2973
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2974
|
+
if (table === undefined) return;
|
|
2975
|
+
batch.table(table.name);
|
|
2976
|
+
if (!image.existed) {
|
|
2977
|
+
deleteLocalRow(this.#db, table, operation.rowId);
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
if (image.values === undefined || image.syncVersion === undefined) {
|
|
2981
|
+
throw new ClientSyncError(
|
|
2982
|
+
'sync.local_corrupt',
|
|
2983
|
+
`rollback image for ${operation.table}/${operation.rowId} is incomplete`,
|
|
2984
|
+
);
|
|
2985
|
+
}
|
|
2986
|
+
const values = table.columns.map((column) =>
|
|
2987
|
+
jsonToRowValue(image.values?.[column.name] ?? null),
|
|
2988
|
+
);
|
|
2989
|
+
upsertLocalRow(this.#db, table, values, image.syncVersion);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
#legacyUndoOptimisticRows(
|
|
2993
|
+
commit: OutboxCommit,
|
|
2994
|
+
batch: ChangeAccumulator,
|
|
2995
|
+
): void {
|
|
2996
|
+
for (const operation of commit.operations) {
|
|
2997
|
+
if (operation.op !== 'upsert') continue;
|
|
2998
|
+
const table = this.#schema.tables.get(operation.table);
|
|
2999
|
+
if (table === undefined) continue;
|
|
3000
|
+
const row = this.#db.query(
|
|
3001
|
+
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
3002
|
+
[operation.rowId],
|
|
3003
|
+
)[0];
|
|
3004
|
+
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
3005
|
+
batch.table(table.name);
|
|
3006
|
+
deleteLocalRow(this.#db, table, operation.rowId);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
#rollbackFailedCommit(commit: OutboxCommit, batch: ChangeAccumulator): void {
|
|
3012
|
+
const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
|
|
3013
|
+
const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
|
|
3014
|
+
const complete = commit.operations.every((_, index) =>
|
|
3015
|
+
imageByIndex.has(index),
|
|
3016
|
+
);
|
|
3017
|
+
const affectedKeys = new Set(
|
|
3018
|
+
commit.operations.map(
|
|
3019
|
+
(operation) => `${operation.table}\u0000${operation.rowId}`,
|
|
3020
|
+
),
|
|
3021
|
+
);
|
|
3022
|
+
if (complete) {
|
|
3023
|
+
const restored = new Set<string>();
|
|
3024
|
+
commit.operations.forEach((operation, index) => {
|
|
3025
|
+
const key = `${operation.table}\u0000${operation.rowId}`;
|
|
3026
|
+
if (restored.has(key)) return;
|
|
3027
|
+
const image = imageByIndex.get(index);
|
|
3028
|
+
if (image) this.#restoreBeforeImage(operation, image, batch);
|
|
3029
|
+
restored.add(key);
|
|
3030
|
+
});
|
|
3031
|
+
} else {
|
|
3032
|
+
// Pending commits written by older clients have no before-images.
|
|
3033
|
+
// Preserve the old fail-closed behavior rather than guessing a base.
|
|
3034
|
+
this.#legacyUndoOptimisticRows(commit, batch);
|
|
3035
|
+
}
|
|
3036
|
+
deleteOutboxCommit(this.#db, commit.clientCommitId);
|
|
3037
|
+
|
|
3038
|
+
if (!complete) return;
|
|
3039
|
+
for (const later of listOutbox(this.#db)) {
|
|
3040
|
+
if (later.seq <= commit.seq) continue;
|
|
3041
|
+
const affectedOperations = later.operations.filter((operation) =>
|
|
3042
|
+
affectedKeys.has(`${operation.table}\u0000${operation.rowId}`),
|
|
3043
|
+
);
|
|
3044
|
+
if (affectedOperations.length === 0) continue;
|
|
3045
|
+
replaceOutboxBeforeImages(
|
|
3046
|
+
this.#db,
|
|
3047
|
+
later.clientCommitId,
|
|
3048
|
+
this.#captureBeforeImages(later.operations, affectedKeys),
|
|
3049
|
+
);
|
|
3050
|
+
this.#applyOperationsLocally(affectedOperations, batch);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
|
|
2948
3054
|
#applyOperationsLocally(
|
|
2949
3055
|
operations: readonly OutboxOperation[],
|
|
2950
3056
|
batch?: ChangeAccumulator,
|
package/src/outbox.ts
CHANGED
|
@@ -43,11 +43,20 @@ export interface OutboxCommit {
|
|
|
43
43
|
readonly operations: readonly OutboxOperation[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Protected local rollback image; never encoded or exposed in outcomes. */
|
|
47
|
+
export interface OutboxBeforeImage {
|
|
48
|
+
readonly opIndex: number;
|
|
49
|
+
readonly existed: boolean;
|
|
50
|
+
readonly syncVersion?: number;
|
|
51
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export function appendOutboxCommit(
|
|
47
55
|
db: ClientDatabase,
|
|
48
56
|
clientCommitId: string,
|
|
49
57
|
operations: readonly OutboxOperation[],
|
|
50
58
|
nowMs: number,
|
|
59
|
+
beforeImages: readonly OutboxBeforeImage[] = [],
|
|
51
60
|
): void {
|
|
52
61
|
if (operations.length === 0) {
|
|
53
62
|
throw new ClientSyncError(
|
|
@@ -60,6 +69,20 @@ export function appendOutboxCommit(
|
|
|
60
69
|
VALUES (?, ?, ?)`,
|
|
61
70
|
[clientCommitId, nowMs, JSON.stringify(operations)],
|
|
62
71
|
);
|
|
72
|
+
for (const image of beforeImages) {
|
|
73
|
+
db.exec(
|
|
74
|
+
`INSERT INTO _syncular_outbox_before_images(
|
|
75
|
+
client_commit_id, op_index, existed, sync_version, values_json
|
|
76
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
77
|
+
[
|
|
78
|
+
clientCommitId,
|
|
79
|
+
image.opIndex,
|
|
80
|
+
image.existed ? 1 : 0,
|
|
81
|
+
image.syncVersion ?? null,
|
|
82
|
+
image.values === undefined ? null : JSON.stringify(image.values),
|
|
83
|
+
],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/** Pending commits in FIFO creation order (§7.1). */
|
|
@@ -81,11 +104,68 @@ export function deleteOutboxCommit(
|
|
|
81
104
|
db: ClientDatabase,
|
|
82
105
|
clientCommitId: string,
|
|
83
106
|
): void {
|
|
107
|
+
db.exec(
|
|
108
|
+
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?',
|
|
109
|
+
[clientCommitId],
|
|
110
|
+
);
|
|
84
111
|
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
|
|
85
112
|
clientCommitId,
|
|
86
113
|
]);
|
|
87
114
|
}
|
|
88
115
|
|
|
116
|
+
export function listOutboxBeforeImages(
|
|
117
|
+
db: ClientDatabase,
|
|
118
|
+
clientCommitId: string,
|
|
119
|
+
): OutboxBeforeImage[] {
|
|
120
|
+
return db
|
|
121
|
+
.query(
|
|
122
|
+
`SELECT op_index, existed, sync_version, values_json
|
|
123
|
+
FROM _syncular_outbox_before_images
|
|
124
|
+
WHERE client_commit_id = ? ORDER BY op_index`,
|
|
125
|
+
[clientCommitId],
|
|
126
|
+
)
|
|
127
|
+
.map((row) => ({
|
|
128
|
+
opIndex: row.op_index as number,
|
|
129
|
+
existed: row.existed === 1,
|
|
130
|
+
...(typeof row.sync_version === 'number'
|
|
131
|
+
? { syncVersion: row.sync_version }
|
|
132
|
+
: {}),
|
|
133
|
+
...(typeof row.values_json === 'string'
|
|
134
|
+
? {
|
|
135
|
+
values: JSON.parse(row.values_json) as Readonly<
|
|
136
|
+
Record<string, JsonRowValue>
|
|
137
|
+
>,
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function replaceOutboxBeforeImages(
|
|
144
|
+
db: ClientDatabase,
|
|
145
|
+
clientCommitId: string,
|
|
146
|
+
replacements: readonly OutboxBeforeImage[],
|
|
147
|
+
): void {
|
|
148
|
+
for (const image of replacements) {
|
|
149
|
+
db.exec(
|
|
150
|
+
`DELETE FROM _syncular_outbox_before_images
|
|
151
|
+
WHERE client_commit_id = ? AND op_index = ?`,
|
|
152
|
+
[clientCommitId, image.opIndex],
|
|
153
|
+
);
|
|
154
|
+
db.exec(
|
|
155
|
+
`INSERT INTO _syncular_outbox_before_images(
|
|
156
|
+
client_commit_id, op_index, existed, sync_version, values_json
|
|
157
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
158
|
+
[
|
|
159
|
+
clientCommitId,
|
|
160
|
+
image.opIndex,
|
|
161
|
+
image.existed ? 1 : 0,
|
|
162
|
+
image.syncVersion ?? null,
|
|
163
|
+
image.values === undefined ? null : JSON.stringify(image.values),
|
|
164
|
+
],
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
89
169
|
/**
|
|
90
170
|
* §7.4.4: after a schema bump, a persisted upsert may name a column the new
|
|
91
171
|
* schema no longer has. The value has nowhere to go and there is no
|
package/src/schema.ts
CHANGED
|
@@ -288,6 +288,13 @@ export function ensureLocalSchema(
|
|
|
288
288
|
client_commit_id TEXT NOT NULL UNIQUE,
|
|
289
289
|
created_at_ms INTEGER NOT NULL,
|
|
290
290
|
operations TEXT NOT NULL)`);
|
|
291
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
|
|
292
|
+
client_commit_id TEXT NOT NULL,
|
|
293
|
+
op_index INTEGER NOT NULL,
|
|
294
|
+
existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
|
|
295
|
+
sync_version INTEGER,
|
|
296
|
+
values_json TEXT,
|
|
297
|
+
PRIMARY KEY(client_commit_id, op_index))`);
|
|
291
298
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
|
|
292
299
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
300
|
client_commit_id TEXT NOT NULL UNIQUE,
|