@syncular/client 0.30.6 → 0.30.9
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 +12 -2
- package/dist/apply.d.ts +2 -2
- package/dist/apply.js +25 -8
- package/dist/client.d.ts +6 -4
- package/dist/client.js +114 -75
- package/dist/database.d.ts +3 -0
- package/dist/database.js +26 -7
- package/dist/diagnostics.d.ts +1 -1
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +61 -15
- package/dist/failed-overlay.d.ts +10 -0
- package/dist/failed-overlay.js +97 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/invalidation.js +3 -0
- package/dist/outcomes.d.ts +10 -0
- package/dist/outcomes.js +18 -3
- package/dist/remote.js +7 -2
- package/dist/schema.js +7 -5
- package/dist/wasm-database.js +5 -5
- package/dist/window.js +4 -4
- package/dist/worker-entry.js +6 -2
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +46 -13
- package/src/client.ts +184 -171
- package/src/database.ts +24 -8
- package/src/diagnostics.ts +1 -0
- package/src/errors.ts +82 -20
- package/src/failed-overlay.ts +165 -0
- package/src/invalidation.ts +4 -0
- package/src/outcomes.ts +41 -3
- package/src/remote.ts +15 -1
- package/src/schema.ts +7 -9
- package/src/wasm-database.ts +5 -8
- package/src/window.ts +4 -7
- package/src/worker-entry.ts +10 -2
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/src/database.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { classifySqliteFailure } from './errors';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Storage abstraction: the client core runs on any SQLite that
|
|
3
5
|
* implements this minimal synchronous surface. Tests use bun:sqlite
|
|
@@ -75,29 +77,43 @@ export function assertImageAlias(alias: string): void {
|
|
|
75
77
|
* `run` executes a parameterless statement.
|
|
76
78
|
*/
|
|
77
79
|
export function runTransaction<T>(
|
|
78
|
-
depthHolder: { depth: number },
|
|
80
|
+
depthHolder: { depth: number; aborted?: { error: unknown } },
|
|
79
81
|
run: (sql: string) => void,
|
|
80
82
|
fn: () => T,
|
|
81
83
|
): T {
|
|
82
84
|
const depth = depthHolder.depth;
|
|
85
|
+
if (depth === 0) delete depthHolder.aborted;
|
|
86
|
+
else if (depthHolder.aborted) throw depthHolder.aborted.error;
|
|
83
87
|
const savepoint = `syncular_sp_${depth}`;
|
|
84
88
|
if (depth === 0) run('BEGIN');
|
|
85
89
|
else run(`SAVEPOINT ${savepoint}`);
|
|
86
90
|
depthHolder.depth = depth + 1;
|
|
87
91
|
try {
|
|
88
92
|
const result = fn();
|
|
93
|
+
const aborted = depthHolder.aborted as { error: unknown } | undefined;
|
|
94
|
+
if (aborted) throw aborted.error;
|
|
89
95
|
depthHolder.depth = depth;
|
|
90
96
|
if (depth === 0) run('COMMIT');
|
|
91
97
|
else run(`RELEASE ${savepoint}`);
|
|
92
98
|
return result;
|
|
93
|
-
} catch (
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
const aborted = depthHolder.aborted as { error: unknown } | undefined;
|
|
101
|
+
const error = aborted?.error ?? caught;
|
|
94
102
|
depthHolder.depth = depth;
|
|
95
|
-
|
|
96
|
-
run('ROLLBACK');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
try {
|
|
104
|
+
if (depth === 0) run('ROLLBACK');
|
|
105
|
+
else {
|
|
106
|
+
run(`ROLLBACK TO ${savepoint}`);
|
|
107
|
+
run(`RELEASE ${savepoint}`);
|
|
108
|
+
}
|
|
109
|
+
} catch (rollbackError) {
|
|
110
|
+
// SQLite can end the whole transaction after SQLITE_FULL or SQLITE_IOERR.
|
|
111
|
+
// Poison remaining nested scopes until the outer owner unwinds.
|
|
112
|
+
depthHolder.aborted = { error };
|
|
113
|
+
if (error instanceof Error && !('rollbackError' in error)) {
|
|
114
|
+
Object.defineProperty(error, 'rollbackError', { value: rollbackError });
|
|
115
|
+
}
|
|
100
116
|
}
|
|
101
|
-
throw error;
|
|
117
|
+
throw classifySqliteFailure(error).error;
|
|
102
118
|
}
|
|
103
119
|
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -151,6 +151,7 @@ export interface DiagnosticQueryFailure {
|
|
|
151
151
|
readonly tables: readonly string[];
|
|
152
152
|
readonly code:
|
|
153
153
|
| 'client.storage_corrupt'
|
|
154
|
+
| 'client.storage_full'
|
|
154
155
|
| 'client.storage_io'
|
|
155
156
|
| 'client.query_failed';
|
|
156
157
|
/** SQLite extended result code, when the driver exposed one. */
|
package/src/errors.ts
CHANGED
|
@@ -9,6 +9,9 @@ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
|
9
9
|
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
10
10
|
export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
11
11
|
|
|
12
|
+
/** Local SQLite storage has no space for the write. */
|
|
13
|
+
export const STORAGE_FULL_CODE = 'client.storage_full';
|
|
14
|
+
|
|
12
15
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
13
16
|
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
14
17
|
|
|
@@ -42,6 +45,14 @@ export class ClientSyncError extends Error {
|
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** Shared invalid-request construction for client validators and transport guards. */
|
|
49
|
+
export function invalidRequest(
|
|
50
|
+
message: string,
|
|
51
|
+
retryable = false,
|
|
52
|
+
): ClientSyncError {
|
|
53
|
+
return new ClientSyncError('sync.invalid_request', message, retryable);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
47
58
|
* for its required coverage. `code` is the attempt's static progress error
|
|
@@ -107,10 +118,45 @@ export function classifySqliteFailure(error: unknown): {
|
|
|
107
118
|
readonly sqliteCode: number | undefined;
|
|
108
119
|
readonly code:
|
|
109
120
|
| typeof STORAGE_CORRUPT_CODE
|
|
121
|
+
| typeof STORAGE_FULL_CODE
|
|
110
122
|
| typeof STORAGE_IO_CODE
|
|
111
123
|
| undefined;
|
|
112
124
|
readonly error: unknown;
|
|
113
125
|
} {
|
|
126
|
+
if (error instanceof ClientSyncError) {
|
|
127
|
+
const sqliteCode = error.details?.sqliteCode;
|
|
128
|
+
let retained = error;
|
|
129
|
+
if (
|
|
130
|
+
'rollbackError' in error &&
|
|
131
|
+
error.details?.rollbackFailure === undefined
|
|
132
|
+
) {
|
|
133
|
+
const rollback = error.rollbackError;
|
|
134
|
+
retained = new ClientSyncError(
|
|
135
|
+
error.code,
|
|
136
|
+
error.message,
|
|
137
|
+
error.retryable,
|
|
138
|
+
{
|
|
139
|
+
...error.details,
|
|
140
|
+
rollbackFailure: {
|
|
141
|
+
message:
|
|
142
|
+
rollback instanceof Error ? rollback.message : String(rollback),
|
|
143
|
+
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
Object.defineProperty(retained, 'cause', { value: error });
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
sqliteCode: typeof sqliteCode === 'number' ? sqliteCode : undefined,
|
|
151
|
+
code:
|
|
152
|
+
error.code === STORAGE_FULL_CODE ||
|
|
153
|
+
error.code === STORAGE_IO_CODE ||
|
|
154
|
+
error.code === STORAGE_CORRUPT_CODE
|
|
155
|
+
? error.code
|
|
156
|
+
: undefined,
|
|
157
|
+
error: retained,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
114
160
|
const sqliteCode =
|
|
115
161
|
error instanceof Error
|
|
116
162
|
? error.name === 'SQLiteError' &&
|
|
@@ -126,25 +172,41 @@ export function classifySqliteFailure(error: unknown): {
|
|
|
126
172
|
: undefined
|
|
127
173
|
: undefined;
|
|
128
174
|
const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
const code =
|
|
176
|
+
primary === 13
|
|
177
|
+
? STORAGE_FULL_CODE
|
|
178
|
+
: primary === 10
|
|
179
|
+
? STORAGE_IO_CODE
|
|
180
|
+
: primary === 11 || primary === 26
|
|
181
|
+
? STORAGE_CORRUPT_CODE
|
|
182
|
+
: undefined;
|
|
183
|
+
if (code === undefined) return { sqliteCode, code, error };
|
|
184
|
+
const rollback =
|
|
185
|
+
error instanceof Error && 'rollbackError' in error
|
|
186
|
+
? error.rollbackError
|
|
187
|
+
: undefined;
|
|
188
|
+
const failure = new ClientSyncError(
|
|
189
|
+
code,
|
|
190
|
+
primary === 13
|
|
191
|
+
? 'local SQLite storage is full'
|
|
192
|
+
: primary === 10
|
|
193
|
+
? 'local SQLite storage I/O failed'
|
|
194
|
+
: 'local SQLite storage is corrupt',
|
|
195
|
+
false,
|
|
196
|
+
{
|
|
141
197
|
sqliteCode,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
198
|
+
sqliteMessage: error instanceof Error ? error.message : String(error),
|
|
199
|
+
...(rollback !== undefined
|
|
200
|
+
? {
|
|
201
|
+
rollbackFailure: {
|
|
202
|
+
message:
|
|
203
|
+
rollback instanceof Error ? rollback.message : String(rollback),
|
|
204
|
+
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
: {}),
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
Object.defineProperty(failure, 'cause', { value: error });
|
|
211
|
+
return { sqliteCode, code, error: failure };
|
|
150
212
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors';
|
|
2
|
+
import type { ClientDatabase } from './database';
|
|
3
|
+
import type { CompiledClientSchema, JsonRowValue } from './schema';
|
|
4
|
+
import { jsonToRowValue, quoteIdent } from './schema';
|
|
5
|
+
import type {
|
|
6
|
+
OutboxCommit,
|
|
7
|
+
OutboxBeforeImage,
|
|
8
|
+
OutboxOperation,
|
|
9
|
+
} from './outbox';
|
|
10
|
+
import { deleteLocalRow, upsertLocalRow } from './apply';
|
|
11
|
+
|
|
12
|
+
/** Protected failure state. The server base advances independently of intent. */
|
|
13
|
+
export function ensureFailedOverlaySchema(db: ClientDatabase): void {
|
|
14
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_failed_rows(
|
|
15
|
+
commit_id TEXT NOT NULL, idx INTEGER NOT NULL,
|
|
16
|
+
tbl TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL,
|
|
17
|
+
op TEXT NOT NULL, intent TEXT, base TEXT, version INTEGER,
|
|
18
|
+
PRIMARY KEY(commit_id, idx))`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function retainedBaseWrite(
|
|
22
|
+
db: ClientDatabase,
|
|
23
|
+
table: string,
|
|
24
|
+
rowId: string,
|
|
25
|
+
values?: Readonly<Record<string, JsonRowValue>>,
|
|
26
|
+
version?: number,
|
|
27
|
+
): void {
|
|
28
|
+
db.exec(
|
|
29
|
+
'UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?',
|
|
30
|
+
[
|
|
31
|
+
values === undefined ? null : JSON.stringify(values),
|
|
32
|
+
version ?? null,
|
|
33
|
+
table,
|
|
34
|
+
rowId,
|
|
35
|
+
],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function retainFailedRows(
|
|
40
|
+
db: ClientDatabase,
|
|
41
|
+
commit: OutboxCommit,
|
|
42
|
+
images: readonly OutboxBeforeImage[],
|
|
43
|
+
): void {
|
|
44
|
+
for (const [index, operation] of commit.operations.entries()) {
|
|
45
|
+
const image = images.find((image) => image.opIndex === index);
|
|
46
|
+
const initial =
|
|
47
|
+
operation.op === 'upsert'
|
|
48
|
+
? { ...image?.values, ...operation.values }
|
|
49
|
+
: undefined;
|
|
50
|
+
if (!image)
|
|
51
|
+
throw new ClientSyncError(
|
|
52
|
+
'sync.local_corrupt',
|
|
53
|
+
'retained intent has no before-image',
|
|
54
|
+
);
|
|
55
|
+
db.exec(
|
|
56
|
+
`INSERT INTO _syncular_failed_rows(commit_id, idx, tbl, id, at, op, intent, base, version)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
58
|
+
[
|
|
59
|
+
commit.clientCommitId,
|
|
60
|
+
index,
|
|
61
|
+
operation.table,
|
|
62
|
+
operation.rowId,
|
|
63
|
+
commit.createdAtMs,
|
|
64
|
+
JSON.stringify(operation),
|
|
65
|
+
initial ? JSON.stringify(initial) : null,
|
|
66
|
+
image.values ? JSON.stringify(image.values) : null,
|
|
67
|
+
image.syncVersion ?? null,
|
|
68
|
+
],
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function restoreFailedBases(
|
|
74
|
+
db: ClientDatabase,
|
|
75
|
+
schema: CompiledClientSchema,
|
|
76
|
+
absentIntent = false,
|
|
77
|
+
): void {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
for (const row of db.query(
|
|
80
|
+
'SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx',
|
|
81
|
+
)) {
|
|
82
|
+
const key = JSON.stringify([row.tbl, row.id]);
|
|
83
|
+
if (!absentIntent && seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
const table = schema.tables.get(String(row.tbl));
|
|
86
|
+
if (!table)
|
|
87
|
+
throw new ClientSyncError(
|
|
88
|
+
'sync.unknown_table',
|
|
89
|
+
'retained intent references an unknown table',
|
|
90
|
+
);
|
|
91
|
+
const json = absentIntent ? row.intent : row.base;
|
|
92
|
+
if (
|
|
93
|
+
absentIntent &&
|
|
94
|
+
(json === null ||
|
|
95
|
+
db.query(
|
|
96
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
97
|
+
[String(row.id)],
|
|
98
|
+
).length)
|
|
99
|
+
)
|
|
100
|
+
continue;
|
|
101
|
+
if (json === null) deleteLocalRow(db, table, String(row.id), false);
|
|
102
|
+
else {
|
|
103
|
+
const values: Record<string, JsonRowValue> = JSON.parse(String(json));
|
|
104
|
+
upsertLocalRow(
|
|
105
|
+
db,
|
|
106
|
+
table,
|
|
107
|
+
table.columns.map((column) =>
|
|
108
|
+
jsonToRowValue(values[column.name] ?? null),
|
|
109
|
+
),
|
|
110
|
+
Number(row.version ?? -1),
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
|
|
118
|
+
const commits = new Map<
|
|
119
|
+
string,
|
|
120
|
+
{
|
|
121
|
+
seq: number;
|
|
122
|
+
clientCommitId: string;
|
|
123
|
+
createdAtMs: number;
|
|
124
|
+
operations: OutboxOperation[];
|
|
125
|
+
}
|
|
126
|
+
>();
|
|
127
|
+
for (const row of db.query(
|
|
128
|
+
'SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx',
|
|
129
|
+
)) {
|
|
130
|
+
const id = String(row.commit_id);
|
|
131
|
+
const operation: OutboxOperation = JSON.parse(String(row.op));
|
|
132
|
+
const prior = commits.get(id);
|
|
133
|
+
if (prior) prior.operations.push(operation);
|
|
134
|
+
else
|
|
135
|
+
commits.set(id, {
|
|
136
|
+
seq: 0,
|
|
137
|
+
clientCommitId: id,
|
|
138
|
+
createdAtMs: Number(row.at),
|
|
139
|
+
operations: [operation],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return [...commits.values()];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function dropFailedRows(
|
|
146
|
+
db: ClientDatabase,
|
|
147
|
+
matches: (
|
|
148
|
+
table: string,
|
|
149
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
150
|
+
) => boolean,
|
|
151
|
+
): OutboxCommit[] {
|
|
152
|
+
const ids = new Set<string>();
|
|
153
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
|
|
154
|
+
const json = row.intent ?? row.base;
|
|
155
|
+
if (json === null) continue;
|
|
156
|
+
const values: Record<string, JsonRowValue> = JSON.parse(String(json));
|
|
157
|
+
if (matches(String(row.tbl), values)) ids.add(String(row.commit_id));
|
|
158
|
+
}
|
|
159
|
+
const dropped = failedOverlayCommits(db).filter((commit) =>
|
|
160
|
+
ids.has(commit.clientCommitId),
|
|
161
|
+
);
|
|
162
|
+
for (const id of ids)
|
|
163
|
+
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
|
|
164
|
+
return dropped;
|
|
165
|
+
}
|
package/src/invalidation.ts
CHANGED
package/src/outcomes.ts
CHANGED
|
@@ -69,6 +69,16 @@ export type CommitOperationOutcome =
|
|
|
69
69
|
readonly rejection: RejectionRecord;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
export interface RetainedCommitRow {
|
|
73
|
+
readonly table: string;
|
|
74
|
+
readonly rowId: string;
|
|
75
|
+
/** Complete intended row, or null for a local deletion. */
|
|
76
|
+
readonly localRow: Readonly<Record<string, RowValue>> | null;
|
|
77
|
+
/** Latest authorized server base, or null for an absent server row. */
|
|
78
|
+
readonly serverRow: Readonly<Record<string, RowValue>> | null;
|
|
79
|
+
readonly serverVersion: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
export interface CommitOutcome {
|
|
73
83
|
/** Monotonic local journal order; not a server sequence. */
|
|
74
84
|
readonly sequence: number;
|
|
@@ -82,6 +92,7 @@ export interface CommitOutcome {
|
|
|
82
92
|
* for successful and historical outcomes. Never sent over the wire.
|
|
83
93
|
*/
|
|
84
94
|
readonly operations?: readonly OutboxOperation[];
|
|
95
|
+
readonly retainedRows?: readonly RetainedCommitRow[];
|
|
85
96
|
readonly resolution: CommitOutcomeResolution;
|
|
86
97
|
readonly resolvedAtMs?: number;
|
|
87
98
|
readonly replacementClientCommitId?: string;
|
|
@@ -154,7 +165,33 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
|
|
|
154
165
|
});
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
function parseOutcome(
|
|
168
|
+
function parseOutcome(
|
|
169
|
+
db: ClientDatabase,
|
|
170
|
+
row: Readonly<Record<string, unknown>>,
|
|
171
|
+
): CommitOutcome {
|
|
172
|
+
const retainedRows: RetainedCommitRow[] = db
|
|
173
|
+
.query(
|
|
174
|
+
'SELECT tbl, id, intent, base, version FROM _syncular_failed_rows WHERE commit_id = ? ORDER BY idx',
|
|
175
|
+
[String(row.client_commit_id)],
|
|
176
|
+
)
|
|
177
|
+
.map((retained) => {
|
|
178
|
+
const decode = (value: unknown): Record<string, RowValue> | null =>
|
|
179
|
+
value === null
|
|
180
|
+
? null
|
|
181
|
+
: Object.fromEntries(
|
|
182
|
+
Object.entries(
|
|
183
|
+
JSON.parse(String(value)) as Record<string, JsonRowValue>,
|
|
184
|
+
).map(([key, value]) => [key, jsonToRowValue(value)]),
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
table: String(retained.tbl),
|
|
188
|
+
rowId: String(retained.id),
|
|
189
|
+
localRow: decode(retained.intent),
|
|
190
|
+
serverRow: decode(retained.base),
|
|
191
|
+
serverVersion:
|
|
192
|
+
retained.version === null ? null : Number(retained.version),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
158
195
|
return {
|
|
159
196
|
sequence: row.seq as number,
|
|
160
197
|
clientCommitId: row.client_commit_id as string,
|
|
@@ -164,6 +201,7 @@ function parseOutcome(row: Readonly<Record<string, unknown>>): CommitOutcome {
|
|
|
164
201
|
...(typeof row.operations === 'string'
|
|
165
202
|
? { operations: JSON.parse(row.operations) as OutboxOperation[] }
|
|
166
203
|
: {}),
|
|
204
|
+
...(retainedRows.length > 0 ? { retainedRows } : {}),
|
|
167
205
|
resolution: row.resolution as CommitOutcomeResolution,
|
|
168
206
|
...(typeof row.resolved_at_ms === 'number'
|
|
169
207
|
? { resolvedAtMs: row.resolved_at_ms }
|
|
@@ -205,7 +243,7 @@ export function commitOutcome(
|
|
|
205
243
|
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
|
|
206
244
|
[clientCommitId],
|
|
207
245
|
)[0];
|
|
208
|
-
return row === undefined ? undefined : parseOutcome(row);
|
|
246
|
+
return row === undefined ? undefined : parseOutcome(db, row);
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
export function listCommitOutcomes(
|
|
@@ -229,7 +267,7 @@ export function listCommitOutcomes(
|
|
|
229
267
|
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
|
|
230
268
|
limit === undefined ? [] : [limit],
|
|
231
269
|
);
|
|
232
|
-
return rows.map(parseOutcome);
|
|
270
|
+
return rows.map((row) => parseOutcome(db, row));
|
|
233
271
|
}
|
|
234
272
|
|
|
235
273
|
export function persistCommitOutcomeResolution(
|
package/src/remote.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type PushOperationResult,
|
|
16
16
|
type PushResultDetailsFrame,
|
|
17
17
|
type PushResultFrame,
|
|
18
|
+
type RowValue,
|
|
18
19
|
type RemoteOperationResponse,
|
|
19
20
|
} from '@syncular/core';
|
|
20
21
|
import type { MutationInput } from './client';
|
|
@@ -25,6 +26,8 @@ import {
|
|
|
25
26
|
compileClientSchema,
|
|
26
27
|
type ClientSchema,
|
|
27
28
|
recordToRowValues,
|
|
29
|
+
normalizeRecordKeys,
|
|
30
|
+
coerceSqlRepresentation,
|
|
28
31
|
} from './schema';
|
|
29
32
|
import type { SyncTransport } from './transport';
|
|
30
33
|
import type {
|
|
@@ -311,7 +314,18 @@ export class SyncRemoteClient {
|
|
|
311
314
|
});
|
|
312
315
|
continue;
|
|
313
316
|
}
|
|
314
|
-
|
|
317
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
318
|
+
let values =
|
|
319
|
+
mutation.op === 'patch'
|
|
320
|
+
? table.columns.map((column) =>
|
|
321
|
+
normalized.has(column.name)
|
|
322
|
+
? (coerceSqlRepresentation(
|
|
323
|
+
column,
|
|
324
|
+
normalized.get(column.name),
|
|
325
|
+
) as RowValue)
|
|
326
|
+
: undefined,
|
|
327
|
+
)
|
|
328
|
+
: recordToRowValues(table, mutation.values);
|
|
315
329
|
const rowId = values[table.primaryKeyIndex];
|
|
316
330
|
if (typeof rowId !== 'string' || rowId.length === 0) {
|
|
317
331
|
throw invalid('remote upsert requires a non-empty string primary key');
|
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureFailedOverlaySchema } from './failed-overlay';
|
|
1
2
|
/**
|
|
2
3
|
* Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
|
|
3
4
|
* compiles and is emitted by codegen. Drives local table
|
|
@@ -6,7 +7,7 @@
|
|
|
6
7
|
*/
|
|
7
8
|
import type { RowColumn, RowValue } from '@syncular/core';
|
|
8
9
|
import type { ClientDatabase, SqlRow, SqlValue } from './database';
|
|
9
|
-
import {
|
|
10
|
+
import { invalidRequest } from './errors';
|
|
10
11
|
import { snakeToCamel } from './naming';
|
|
11
12
|
|
|
12
13
|
/** `'prefix:{variable}'` shorthand (column name = variable) or explicit. */
|
|
@@ -532,6 +533,7 @@ export function ensureLocalSyncedSchema(
|
|
|
532
533
|
*/
|
|
533
534
|
export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
534
535
|
db.transaction(() => {
|
|
536
|
+
ensureFailedOverlaySchema(db);
|
|
535
537
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
536
538
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
537
539
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
|
|
@@ -707,20 +709,17 @@ export function normalizeRecordKeys(
|
|
|
707
709
|
table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
|
|
708
710
|
if (index === undefined) {
|
|
709
711
|
if (key.startsWith('_sync_')) {
|
|
710
|
-
throw
|
|
711
|
-
'sync.invalid_request',
|
|
712
|
+
throw invalidRequest(
|
|
712
713
|
`table ${table.name}: ${JSON.stringify(key)} is an internal sync column and cannot appear in mutation values — did you build this record from a raw SELECT * row? (client.query() strips _sync_* columns; rows read via client.database keep them)`,
|
|
713
714
|
);
|
|
714
715
|
}
|
|
715
|
-
throw
|
|
716
|
-
'sync.invalid_request',
|
|
716
|
+
throw invalidRequest(
|
|
717
717
|
`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`,
|
|
718
718
|
);
|
|
719
719
|
}
|
|
720
720
|
const sqlName = (table.columns[index] as RowColumn).name;
|
|
721
721
|
if (normalized.has(sqlName)) {
|
|
722
|
-
throw
|
|
723
|
-
'sync.invalid_request',
|
|
722
|
+
throw invalidRequest(
|
|
724
723
|
`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`,
|
|
725
724
|
);
|
|
726
725
|
}
|
|
@@ -765,8 +764,7 @@ export function recordToRowValues(
|
|
|
765
764
|
const value = normalized.get(column.name);
|
|
766
765
|
if (value === undefined || value === null) {
|
|
767
766
|
if (!column.nullable) {
|
|
768
|
-
throw
|
|
769
|
-
'sync.invalid_request',
|
|
767
|
+
throw invalidRequest(
|
|
770
768
|
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`,
|
|
771
769
|
);
|
|
772
770
|
}
|
package/src/wasm-database.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from './database';
|
|
34
34
|
import {
|
|
35
35
|
ClientSyncError,
|
|
36
|
+
invalidRequest,
|
|
36
37
|
STORAGE_BUSY_CODE,
|
|
37
38
|
STORAGE_UNAVAILABLE_CODE,
|
|
38
39
|
} from './errors';
|
|
@@ -205,8 +206,7 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
205
206
|
assertImageAlias(alias);
|
|
206
207
|
const pointer = this.#db.pointer;
|
|
207
208
|
if (pointer === undefined) {
|
|
208
|
-
throw
|
|
209
|
-
'sync.invalid_request',
|
|
209
|
+
throw invalidRequest(
|
|
210
210
|
'sqlite-wasm database exposes no native handle for image import',
|
|
211
211
|
);
|
|
212
212
|
}
|
|
@@ -225,8 +225,7 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
225
225
|
capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_READONLY,
|
|
226
226
|
);
|
|
227
227
|
if (rc !== 0) {
|
|
228
|
-
throw
|
|
229
|
-
'sync.invalid_request',
|
|
228
|
+
throw invalidRequest(
|
|
230
229
|
`sqlite3_deserialize failed with code ${rc} (§5.3)`,
|
|
231
230
|
);
|
|
232
231
|
}
|
|
@@ -398,14 +397,12 @@ export async function openPersistentWasmDatabase(
|
|
|
398
397
|
options?: PersistentWasmDatabaseOptions,
|
|
399
398
|
): Promise<ClientDatabase> {
|
|
400
399
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
401
|
-
throw
|
|
402
|
-
'sync.invalid_request',
|
|
400
|
+
throw invalidRequest(
|
|
403
401
|
`invalid persistent database name ${JSON.stringify(name)}`,
|
|
404
402
|
);
|
|
405
403
|
}
|
|
406
404
|
if (!inWorkerContext()) {
|
|
407
|
-
throw
|
|
408
|
-
'sync.invalid_request',
|
|
405
|
+
throw invalidRequest(
|
|
409
406
|
'openPersistentWasmDatabase() must run inside a Web Worker: the ' +
|
|
410
407
|
'persistent mode is whole-core-in-a-worker by design (Direction ' +
|
|
411
408
|
'decision 2, 2026-07-03). Use the worker handle from the main ' +
|
package/src/window.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { canonicalScopeJson, type ScopeMap } from '@syncular/core';
|
|
16
16
|
import type { ClientDatabase } from './database';
|
|
17
|
-
import {
|
|
17
|
+
import { invalidRequest } from './errors';
|
|
18
18
|
|
|
19
19
|
export type TimeBucketUnit = 'month';
|
|
20
20
|
|
|
@@ -35,8 +35,7 @@ export function creationTimeBucket(
|
|
|
35
35
|
createdAtMs < 0 ||
|
|
36
36
|
createdAtMs > MAX_TIME_BUCKET_MS
|
|
37
37
|
) {
|
|
38
|
-
throw
|
|
39
|
-
'sync.invalid_request',
|
|
38
|
+
throw invalidRequest(
|
|
40
39
|
'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999',
|
|
41
40
|
);
|
|
42
41
|
}
|
|
@@ -59,16 +58,14 @@ export function last(
|
|
|
59
58
|
nowMs < 0 ||
|
|
60
59
|
nowMs > MAX_TIME_BUCKET_MS
|
|
61
60
|
) {
|
|
62
|
-
throw
|
|
63
|
-
'sync.invalid_request',
|
|
61
|
+
throw invalidRequest(
|
|
64
62
|
'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999',
|
|
65
63
|
);
|
|
66
64
|
}
|
|
67
65
|
const date = new Date(nowMs);
|
|
68
66
|
const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
|
|
69
67
|
if (current - (count - 1) < 1970 * 12) {
|
|
70
|
-
throw
|
|
71
|
-
'sync.invalid_request',
|
|
68
|
+
throw invalidRequest(
|
|
72
69
|
'last requires every returned UTC month to fall from 1970 through 9999',
|
|
73
70
|
);
|
|
74
71
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
withClientDiagnosticsHost,
|
|
21
21
|
} from './diagnostics';
|
|
22
22
|
import { encryptionConfigFromKeyring } from './encryption';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
classifySqliteFailure,
|
|
25
|
+
ClientSyncError,
|
|
26
|
+
STORAGE_BUSY_CODE,
|
|
27
|
+
} from './errors';
|
|
24
28
|
import {
|
|
25
29
|
httpBlobTransport,
|
|
26
30
|
httpSegmentDownloader,
|
|
@@ -77,7 +81,8 @@ interface WorkerScope {
|
|
|
77
81
|
): void;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
function toErrorShape(
|
|
84
|
+
function toErrorShape(caught: unknown): WorkerErrorShape {
|
|
85
|
+
const error = classifySqliteFailure(caught).error;
|
|
81
86
|
if (error instanceof ClientSyncError) {
|
|
82
87
|
return {
|
|
83
88
|
code: error.code,
|
|
@@ -350,6 +355,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
350
355
|
},
|
|
351
356
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
352
357
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
358
|
+
...(config.retainFailedCommits !== undefined
|
|
359
|
+
? { retainFailedCommits: config.retainFailedCommits }
|
|
360
|
+
: {}),
|
|
353
361
|
...(config.realtimePolicy !== undefined
|
|
354
362
|
? { realtimePolicy: config.realtimePolicy }
|
|
355
363
|
: {}),
|
package/src/worker-host.ts
CHANGED
|
@@ -196,6 +196,7 @@ export interface SyncClientHandleConfig {
|
|
|
196
196
|
readonly securityPreflight?: boolean;
|
|
197
197
|
readonly clientId?: string;
|
|
198
198
|
readonly limits?: SyncClientLimits;
|
|
199
|
+
readonly retainFailedCommits?: boolean;
|
|
199
200
|
/**
|
|
200
201
|
* SPEC §8.8 realtime policy for the worker-owned core, forwarded into
|
|
201
202
|
* `WorkerInitConfig`. Absent ⇒ `optional`.
|
|
@@ -929,6 +930,9 @@ function buildInitConfig(
|
|
|
929
930
|
: {}),
|
|
930
931
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
931
932
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
933
|
+
...(config.retainFailedCommits !== undefined
|
|
934
|
+
? { retainFailedCommits: config.retainFailedCommits }
|
|
935
|
+
: {}),
|
|
932
936
|
...(config.realtimePolicy !== undefined
|
|
933
937
|
? { realtimePolicy: config.realtimePolicy }
|
|
934
938
|
: {}),
|