@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/dist/database.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifySqliteFailure } from './errors.js';
|
|
1
2
|
/** Attach-schema aliases are code-chosen, never user input — enforce it. */
|
|
2
3
|
export function assertImageAlias(alias) {
|
|
3
4
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
|
|
@@ -10,6 +11,10 @@ export function assertImageAlias(alias) {
|
|
|
10
11
|
*/
|
|
11
12
|
export function runTransaction(depthHolder, run, fn) {
|
|
12
13
|
const depth = depthHolder.depth;
|
|
14
|
+
if (depth === 0)
|
|
15
|
+
delete depthHolder.aborted;
|
|
16
|
+
else if (depthHolder.aborted)
|
|
17
|
+
throw depthHolder.aborted.error;
|
|
13
18
|
const savepoint = `syncular_sp_${depth}`;
|
|
14
19
|
if (depth === 0)
|
|
15
20
|
run('BEGIN');
|
|
@@ -18,6 +23,9 @@ export function runTransaction(depthHolder, run, fn) {
|
|
|
18
23
|
depthHolder.depth = depth + 1;
|
|
19
24
|
try {
|
|
20
25
|
const result = fn();
|
|
26
|
+
const aborted = depthHolder.aborted;
|
|
27
|
+
if (aborted)
|
|
28
|
+
throw aborted.error;
|
|
21
29
|
depthHolder.depth = depth;
|
|
22
30
|
if (depth === 0)
|
|
23
31
|
run('COMMIT');
|
|
@@ -25,15 +33,26 @@ export function runTransaction(depthHolder, run, fn) {
|
|
|
25
33
|
run(`RELEASE ${savepoint}`);
|
|
26
34
|
return result;
|
|
27
35
|
}
|
|
28
|
-
catch (
|
|
36
|
+
catch (caught) {
|
|
37
|
+
const aborted = depthHolder.aborted;
|
|
38
|
+
const error = aborted?.error ?? caught;
|
|
29
39
|
depthHolder.depth = depth;
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
try {
|
|
41
|
+
if (depth === 0)
|
|
42
|
+
run('ROLLBACK');
|
|
43
|
+
else {
|
|
44
|
+
run(`ROLLBACK TO ${savepoint}`);
|
|
45
|
+
run(`RELEASE ${savepoint}`);
|
|
46
|
+
}
|
|
32
47
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
catch (rollbackError) {
|
|
49
|
+
// SQLite can end the whole transaction after SQLITE_FULL or SQLITE_IOERR.
|
|
50
|
+
// Poison remaining nested scopes until the outer owner unwinds.
|
|
51
|
+
depthHolder.aborted = { error };
|
|
52
|
+
if (error instanceof Error && !('rollbackError' in error)) {
|
|
53
|
+
Object.defineProperty(error, 'rollbackError', { value: rollbackError });
|
|
54
|
+
}
|
|
36
55
|
}
|
|
37
|
-
throw error;
|
|
56
|
+
throw classifySqliteFailure(error).error;
|
|
38
57
|
}
|
|
39
58
|
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -113,7 +113,7 @@ export interface DiagnosticQueryFailure {
|
|
|
113
113
|
readonly id: string;
|
|
114
114
|
/** Distinct generated table names the query depends on, ascending. */
|
|
115
115
|
readonly tables: readonly string[];
|
|
116
|
-
readonly code: 'client.storage_corrupt' | 'client.storage_io' | 'client.query_failed';
|
|
116
|
+
readonly code: 'client.storage_corrupt' | 'client.storage_full' | 'client.storage_io' | 'client.query_failed';
|
|
117
117
|
/** SQLite extended result code, when the driver exposed one. */
|
|
118
118
|
readonly sqliteCode?: number;
|
|
119
119
|
/** First failure since this id's last successful read. */
|
package/dist/errors.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const STORAGE_BUSY_CODE = "client.storage_busy";
|
|
|
5
5
|
export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
|
|
6
6
|
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
7
7
|
export declare const STORAGE_CORRUPT_CODE = "client.storage_corrupt";
|
|
8
|
+
/** Local SQLite storage has no space for the write. */
|
|
9
|
+
export declare const STORAGE_FULL_CODE = "client.storage_full";
|
|
8
10
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
9
11
|
export declare const STORAGE_IO_CODE = "client.storage_io";
|
|
10
12
|
/** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
|
|
@@ -23,6 +25,8 @@ export declare class ClientSyncError extends Error {
|
|
|
23
25
|
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
24
26
|
constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
|
|
25
27
|
}
|
|
28
|
+
/** Shared invalid-request construction for client validators and transport guards. */
|
|
29
|
+
export declare function invalidRequest(message: string, retryable?: boolean): ClientSyncError;
|
|
26
30
|
/**
|
|
27
31
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
28
32
|
* for its required coverage. `code` is the attempt's static progress error
|
|
@@ -61,6 +65,6 @@ export declare class RealtimeUnavailableError extends ClientSyncError {
|
|
|
61
65
|
*/
|
|
62
66
|
export declare function classifySqliteFailure(error: unknown): {
|
|
63
67
|
readonly sqliteCode: number | undefined;
|
|
64
|
-
readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_IO_CODE | undefined;
|
|
68
|
+
readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_FULL_CODE | typeof STORAGE_IO_CODE | undefined;
|
|
65
69
|
readonly error: unknown;
|
|
66
70
|
};
|
package/dist/errors.js
CHANGED
|
@@ -4,6 +4,8 @@ export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
|
4
4
|
export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
5
5
|
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
6
6
|
export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
7
|
+
/** Local SQLite storage has no space for the write. */
|
|
8
|
+
export const STORAGE_FULL_CODE = 'client.storage_full';
|
|
7
9
|
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
8
10
|
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
9
11
|
/** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
|
|
@@ -27,6 +29,10 @@ export class ClientSyncError extends Error {
|
|
|
27
29
|
this.details = details;
|
|
28
30
|
}
|
|
29
31
|
}
|
|
32
|
+
/** Shared invalid-request construction for client validators and transport guards. */
|
|
33
|
+
export function invalidRequest(message, retryable = false) {
|
|
34
|
+
return new ClientSyncError('sync.invalid_request', message, retryable);
|
|
35
|
+
}
|
|
30
36
|
/**
|
|
31
37
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
|
32
38
|
* for its required coverage. `code` is the attempt's static progress error
|
|
@@ -73,6 +79,31 @@ export class RealtimeUnavailableError extends ClientSyncError {
|
|
|
73
79
|
* original value.
|
|
74
80
|
*/
|
|
75
81
|
export function classifySqliteFailure(error) {
|
|
82
|
+
if (error instanceof ClientSyncError) {
|
|
83
|
+
const sqliteCode = error.details?.sqliteCode;
|
|
84
|
+
let retained = error;
|
|
85
|
+
if ('rollbackError' in error &&
|
|
86
|
+
error.details?.rollbackFailure === undefined) {
|
|
87
|
+
const rollback = error.rollbackError;
|
|
88
|
+
retained = new ClientSyncError(error.code, error.message, error.retryable, {
|
|
89
|
+
...error.details,
|
|
90
|
+
rollbackFailure: {
|
|
91
|
+
message: rollback instanceof Error ? rollback.message : String(rollback),
|
|
92
|
+
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
Object.defineProperty(retained, 'cause', { value: error });
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
sqliteCode: typeof sqliteCode === 'number' ? sqliteCode : undefined,
|
|
99
|
+
code: error.code === STORAGE_FULL_CODE ||
|
|
100
|
+
error.code === STORAGE_IO_CODE ||
|
|
101
|
+
error.code === STORAGE_CORRUPT_CODE
|
|
102
|
+
? error.code
|
|
103
|
+
: undefined,
|
|
104
|
+
error: retained,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
76
107
|
const sqliteCode = error instanceof Error
|
|
77
108
|
? error.name === 'SQLiteError' &&
|
|
78
109
|
'errno' in error &&
|
|
@@ -87,19 +118,34 @@ export function classifySqliteFailure(error) {
|
|
|
87
118
|
: undefined
|
|
88
119
|
: undefined;
|
|
89
120
|
const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
const code = primary === 13
|
|
122
|
+
? STORAGE_FULL_CODE
|
|
123
|
+
: primary === 10
|
|
124
|
+
? STORAGE_IO_CODE
|
|
125
|
+
: primary === 11 || primary === 26
|
|
126
|
+
? STORAGE_CORRUPT_CODE
|
|
127
|
+
: undefined;
|
|
128
|
+
if (code === undefined)
|
|
129
|
+
return { sqliteCode, code, error };
|
|
130
|
+
const rollback = error instanceof Error && 'rollbackError' in error
|
|
131
|
+
? error.rollbackError
|
|
132
|
+
: undefined;
|
|
133
|
+
const failure = new ClientSyncError(code, primary === 13
|
|
134
|
+
? 'local SQLite storage is full'
|
|
135
|
+
: primary === 10
|
|
136
|
+
? 'local SQLite storage I/O failed'
|
|
137
|
+
: 'local SQLite storage is corrupt', false, {
|
|
138
|
+
sqliteCode,
|
|
139
|
+
sqliteMessage: error instanceof Error ? error.message : String(error),
|
|
140
|
+
...(rollback !== undefined
|
|
141
|
+
? {
|
|
142
|
+
rollbackFailure: {
|
|
143
|
+
message: rollback instanceof Error ? rollback.message : String(rollback),
|
|
144
|
+
sqliteCode: classifySqliteFailure(rollback).sqliteCode,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
});
|
|
149
|
+
Object.defineProperty(failure, 'cause', { value: error });
|
|
150
|
+
return { sqliteCode, code, error: failure };
|
|
105
151
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClientDatabase } from './database.js';
|
|
2
|
+
import type { CompiledClientSchema, JsonRowValue } from './schema.js';
|
|
3
|
+
import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
|
|
4
|
+
/** Protected failure state. The server base advances independently of intent. */
|
|
5
|
+
export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
|
|
6
|
+
export declare function retainedBaseWrite(db: ClientDatabase, table: string, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
|
|
7
|
+
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
|
|
8
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
|
|
9
|
+
export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
|
|
10
|
+
export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { jsonToRowValue, quoteIdent } from './schema.js';
|
|
3
|
+
import { deleteLocalRow, upsertLocalRow } from './apply.js';
|
|
4
|
+
/** Protected failure state. The server base advances independently of intent. */
|
|
5
|
+
export function ensureFailedOverlaySchema(db) {
|
|
6
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_failed_rows(
|
|
7
|
+
commit_id TEXT NOT NULL, idx INTEGER NOT NULL,
|
|
8
|
+
tbl TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL,
|
|
9
|
+
op TEXT NOT NULL, intent TEXT, base TEXT, version INTEGER,
|
|
10
|
+
PRIMARY KEY(commit_id, idx))`);
|
|
11
|
+
}
|
|
12
|
+
export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
13
|
+
db.exec('UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?', [
|
|
14
|
+
values === undefined ? null : JSON.stringify(values),
|
|
15
|
+
version ?? null,
|
|
16
|
+
table,
|
|
17
|
+
rowId,
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
export function retainFailedRows(db, commit, images) {
|
|
21
|
+
for (const [index, operation] of commit.operations.entries()) {
|
|
22
|
+
const image = images.find((image) => image.opIndex === index);
|
|
23
|
+
const initial = operation.op === 'upsert'
|
|
24
|
+
? { ...image?.values, ...operation.values }
|
|
25
|
+
: undefined;
|
|
26
|
+
if (!image)
|
|
27
|
+
throw new ClientSyncError('sync.local_corrupt', 'retained intent has no before-image');
|
|
28
|
+
db.exec(`INSERT INTO _syncular_failed_rows(commit_id, idx, tbl, id, at, op, intent, base, version)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
30
|
+
commit.clientCommitId,
|
|
31
|
+
index,
|
|
32
|
+
operation.table,
|
|
33
|
+
operation.rowId,
|
|
34
|
+
commit.createdAtMs,
|
|
35
|
+
JSON.stringify(operation),
|
|
36
|
+
initial ? JSON.stringify(initial) : null,
|
|
37
|
+
image.values ? JSON.stringify(image.values) : null,
|
|
38
|
+
image.syncVersion ?? null,
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function restoreFailedBases(db, schema, absentIntent = false) {
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
|
|
45
|
+
const key = JSON.stringify([row.tbl, row.id]);
|
|
46
|
+
if (!absentIntent && seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
const table = schema.tables.get(String(row.tbl));
|
|
50
|
+
if (!table)
|
|
51
|
+
throw new ClientSyncError('sync.unknown_table', 'retained intent references an unknown table');
|
|
52
|
+
const json = absentIntent ? row.intent : row.base;
|
|
53
|
+
if (absentIntent &&
|
|
54
|
+
(json === null ||
|
|
55
|
+
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [String(row.id)]).length))
|
|
56
|
+
continue;
|
|
57
|
+
if (json === null)
|
|
58
|
+
deleteLocalRow(db, table, String(row.id), false);
|
|
59
|
+
else {
|
|
60
|
+
const values = JSON.parse(String(json));
|
|
61
|
+
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), Number(row.version ?? -1), false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function failedOverlayCommits(db) {
|
|
66
|
+
const commits = new Map();
|
|
67
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
|
|
68
|
+
const id = String(row.commit_id);
|
|
69
|
+
const operation = JSON.parse(String(row.op));
|
|
70
|
+
const prior = commits.get(id);
|
|
71
|
+
if (prior)
|
|
72
|
+
prior.operations.push(operation);
|
|
73
|
+
else
|
|
74
|
+
commits.set(id, {
|
|
75
|
+
seq: 0,
|
|
76
|
+
clientCommitId: id,
|
|
77
|
+
createdAtMs: Number(row.at),
|
|
78
|
+
operations: [operation],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return [...commits.values()];
|
|
82
|
+
}
|
|
83
|
+
export function dropFailedRows(db, matches) {
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
|
|
86
|
+
const json = row.intent ?? row.base;
|
|
87
|
+
if (json === null)
|
|
88
|
+
continue;
|
|
89
|
+
const values = JSON.parse(String(json));
|
|
90
|
+
if (matches(String(row.tbl), values))
|
|
91
|
+
ids.add(String(row.commit_id));
|
|
92
|
+
}
|
|
93
|
+
const dropped = failedOverlayCommits(db).filter((commit) => ids.has(commit.clientCommitId));
|
|
94
|
+
for (const id of ids)
|
|
95
|
+
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
|
|
96
|
+
return dropped;
|
|
97
|
+
}
|
package/dist/invalidation.d.ts
CHANGED
|
@@ -83,6 +83,7 @@ export declare class ChangeAccumulator {
|
|
|
83
83
|
scopeMap(table: CompiledClientTable, scopes: ScopeMap): void;
|
|
84
84
|
/** Add precise keys for a COMMIT change's stored scope values. */
|
|
85
85
|
changeScopes(table: CompiledClientTable, scopes: Readonly<Record<string, string>>): void;
|
|
86
|
+
hasTable(name: string): boolean;
|
|
86
87
|
get touched(): boolean;
|
|
87
88
|
get statusChanged(): boolean;
|
|
88
89
|
finish(revision: LocalRevision, status: SyncStatusSnapshot | undefined): ClientChangeBatch;
|
package/dist/invalidation.js
CHANGED
package/dist/outcomes.d.ts
CHANGED
|
@@ -50,6 +50,15 @@ export type CommitOperationOutcome = {
|
|
|
50
50
|
readonly status: 'error';
|
|
51
51
|
readonly rejection: RejectionRecord;
|
|
52
52
|
};
|
|
53
|
+
export interface RetainedCommitRow {
|
|
54
|
+
readonly table: string;
|
|
55
|
+
readonly rowId: string;
|
|
56
|
+
/** Complete intended row, or null for a local deletion. */
|
|
57
|
+
readonly localRow: Readonly<Record<string, RowValue>> | null;
|
|
58
|
+
/** Latest authorized server base, or null for an absent server row. */
|
|
59
|
+
readonly serverRow: Readonly<Record<string, RowValue>> | null;
|
|
60
|
+
readonly serverVersion: number | null;
|
|
61
|
+
}
|
|
53
62
|
export interface CommitOutcome {
|
|
54
63
|
/** Monotonic local journal order; not a server sequence. */
|
|
55
64
|
readonly sequence: number;
|
|
@@ -63,6 +72,7 @@ export interface CommitOutcome {
|
|
|
63
72
|
* for successful and historical outcomes. Never sent over the wire.
|
|
64
73
|
*/
|
|
65
74
|
readonly operations?: readonly OutboxOperation[];
|
|
75
|
+
readonly retainedRows?: readonly RetainedCommitRow[];
|
|
66
76
|
readonly resolution: CommitOutcomeResolution;
|
|
67
77
|
readonly resolvedAtMs?: number;
|
|
68
78
|
readonly replacementClientCommitId?: string;
|
package/dist/outcomes.js
CHANGED
|
@@ -36,7 +36,21 @@ function decodeResults(raw) {
|
|
|
36
36
|
};
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
-
function parseOutcome(row) {
|
|
39
|
+
function parseOutcome(db, row) {
|
|
40
|
+
const retainedRows = db
|
|
41
|
+
.query('SELECT tbl, id, intent, base, version FROM _syncular_failed_rows WHERE commit_id = ? ORDER BY idx', [String(row.client_commit_id)])
|
|
42
|
+
.map((retained) => {
|
|
43
|
+
const decode = (value) => value === null
|
|
44
|
+
? null
|
|
45
|
+
: Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
|
|
46
|
+
return {
|
|
47
|
+
table: String(retained.tbl),
|
|
48
|
+
rowId: String(retained.id),
|
|
49
|
+
localRow: decode(retained.intent),
|
|
50
|
+
serverRow: decode(retained.base),
|
|
51
|
+
serverVersion: retained.version === null ? null : Number(retained.version),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
40
54
|
return {
|
|
41
55
|
sequence: row.seq,
|
|
42
56
|
clientCommitId: row.client_commit_id,
|
|
@@ -46,6 +60,7 @@ function parseOutcome(row) {
|
|
|
46
60
|
...(typeof row.operations === 'string'
|
|
47
61
|
? { operations: JSON.parse(row.operations) }
|
|
48
62
|
: {}),
|
|
63
|
+
...(retainedRows.length > 0 ? { retainedRows } : {}),
|
|
49
64
|
resolution: row.resolution,
|
|
50
65
|
...(typeof row.resolved_at_ms === 'number'
|
|
51
66
|
? { resolvedAtMs: row.resolved_at_ms }
|
|
@@ -73,7 +88,7 @@ export function commitOutcome(db, clientCommitId) {
|
|
|
73
88
|
const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
|
|
74
89
|
resolution, resolved_at_ms, replacement_client_commit_id
|
|
75
90
|
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
|
|
76
|
-
return row === undefined ? undefined : parseOutcome(row);
|
|
91
|
+
return row === undefined ? undefined : parseOutcome(db, row);
|
|
77
92
|
}
|
|
78
93
|
export function listCommitOutcomes(db, query = {}) {
|
|
79
94
|
const limit = query.limit;
|
|
@@ -87,7 +102,7 @@ export function listCommitOutcomes(db, query = {}) {
|
|
|
87
102
|
resolution, resolved_at_ms, replacement_client_commit_id
|
|
88
103
|
FROM _syncular_commit_outcomes ${where}
|
|
89
104
|
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
90
|
-
return rows.map(parseOutcome);
|
|
105
|
+
return rows.map((row) => parseOutcome(db, row));
|
|
91
106
|
}
|
|
92
107
|
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
93
108
|
db.exec(`UPDATE _syncular_commit_outcomes
|
package/dist/remote.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
6
6
|
import { encryptRowValues } from './encryption.js';
|
|
7
7
|
import { ClientSyncError } from './errors.js';
|
|
8
|
-
import { compileClientSchema, recordToRowValues, } from './schema.js';
|
|
8
|
+
import { compileClientSchema, recordToRowValues, normalizeRecordKeys, coerceSqlRepresentation, } from './schema.js';
|
|
9
9
|
export function remoteCommand(id) {
|
|
10
10
|
if (id.length === 0)
|
|
11
11
|
throw invalid('remote command id must be non-empty');
|
|
@@ -157,7 +157,12 @@ export class SyncRemoteClient {
|
|
|
157
157
|
});
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
161
|
+
let values = mutation.op === 'patch'
|
|
162
|
+
? table.columns.map((column) => normalized.has(column.name)
|
|
163
|
+
? coerceSqlRepresentation(column, normalized.get(column.name))
|
|
164
|
+
: undefined)
|
|
165
|
+
: recordToRowValues(table, mutation.values);
|
|
161
166
|
const rowId = values[table.primaryKeyIndex];
|
|
162
167
|
if (typeof rowId !== 'string' || rowId.length === 0) {
|
|
163
168
|
throw invalid('remote upsert requires a non-empty string primary key');
|
package/dist/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ensureFailedOverlaySchema } from './failed-overlay.js';
|
|
2
|
+
import { invalidRequest } from './errors.js';
|
|
2
3
|
import { snakeToCamel } from './naming.js';
|
|
3
4
|
const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
|
|
4
5
|
const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
@@ -361,6 +362,7 @@ export function ensureLocalSyncedSchema(db, schema) {
|
|
|
361
362
|
*/
|
|
362
363
|
export function ensureLocalBookkeepingSchema(db) {
|
|
363
364
|
db.transaction(() => {
|
|
365
|
+
ensureFailedOverlaySchema(db);
|
|
364
366
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
365
367
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
366
368
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
|
|
@@ -510,13 +512,13 @@ export function normalizeRecordKeys(table, record) {
|
|
|
510
512
|
const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
|
|
511
513
|
if (index === undefined) {
|
|
512
514
|
if (key.startsWith('_sync_')) {
|
|
513
|
-
throw
|
|
515
|
+
throw invalidRequest(`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)`);
|
|
514
516
|
}
|
|
515
|
-
throw
|
|
517
|
+
throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`);
|
|
516
518
|
}
|
|
517
519
|
const sqlName = table.columns[index].name;
|
|
518
520
|
if (normalized.has(sqlName)) {
|
|
519
|
-
throw
|
|
521
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`);
|
|
520
522
|
}
|
|
521
523
|
normalized.set(sqlName, value);
|
|
522
524
|
}
|
|
@@ -551,7 +553,7 @@ export function recordToRowValues(table, record) {
|
|
|
551
553
|
const value = normalized.get(column.name);
|
|
552
554
|
if (value === undefined || value === null) {
|
|
553
555
|
if (!column.nullable) {
|
|
554
|
-
throw
|
|
556
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`);
|
|
555
557
|
}
|
|
556
558
|
return null;
|
|
557
559
|
}
|
package/dist/wasm-database.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
26
26
|
import { assertImageAlias, runTransaction, } from './database.js';
|
|
27
|
-
import { ClientSyncError, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
|
|
27
|
+
import { ClientSyncError, invalidRequest, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
|
|
28
28
|
function coerceParams(params) {
|
|
29
29
|
return params.map((value) => {
|
|
30
30
|
if (typeof value === 'boolean')
|
|
@@ -108,7 +108,7 @@ class WasmClientDatabase {
|
|
|
108
108
|
assertImageAlias(alias);
|
|
109
109
|
const pointer = this.#db.pointer;
|
|
110
110
|
if (pointer === undefined) {
|
|
111
|
-
throw
|
|
111
|
+
throw invalidRequest('sqlite-wasm database exposes no native handle for image import');
|
|
112
112
|
}
|
|
113
113
|
const { capi, wasm } = this.#sqlite3;
|
|
114
114
|
this.exec(`ATTACH ':memory:' AS ${alias}`);
|
|
@@ -119,7 +119,7 @@ class WasmClientDatabase {
|
|
|
119
119
|
// immutable inputs (§5.1).
|
|
120
120
|
capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_READONLY);
|
|
121
121
|
if (rc !== 0) {
|
|
122
|
-
throw
|
|
122
|
+
throw invalidRequest(`sqlite3_deserialize failed with code ${rc} (§5.3)`);
|
|
123
123
|
}
|
|
124
124
|
return await fn();
|
|
125
125
|
}
|
|
@@ -235,10 +235,10 @@ function configureSahCrashRecovery(db, sqlite3) {
|
|
|
235
235
|
*/
|
|
236
236
|
export async function openPersistentWasmDatabase(name, options) {
|
|
237
237
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
238
|
-
throw
|
|
238
|
+
throw invalidRequest(`invalid persistent database name ${JSON.stringify(name)}`);
|
|
239
239
|
}
|
|
240
240
|
if (!inWorkerContext()) {
|
|
241
|
-
throw
|
|
241
|
+
throw invalidRequest('openPersistentWasmDatabase() must run inside a Web Worker: the ' +
|
|
242
242
|
'persistent mode is whole-core-in-a-worker by design (Direction ' +
|
|
243
243
|
'decision 2, 2026-07-03). Use the worker handle from the main ' +
|
|
244
244
|
'thread, or openWasmDatabase() for explicitly ephemeral state.');
|
package/dist/window.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* transaction and the invalidation choke point.
|
|
14
14
|
*/
|
|
15
15
|
import { canonicalScopeJson } from '@syncular/core';
|
|
16
|
-
import {
|
|
16
|
+
import { invalidRequest } from './errors.js';
|
|
17
17
|
const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
|
|
18
18
|
function monthBucket(year, month) {
|
|
19
19
|
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
|
|
@@ -24,7 +24,7 @@ export function creationTimeBucket(createdAtMs, unit) {
|
|
|
24
24
|
!Number.isSafeInteger(createdAtMs) ||
|
|
25
25
|
createdAtMs < 0 ||
|
|
26
26
|
createdAtMs > MAX_TIME_BUCKET_MS) {
|
|
27
|
-
throw
|
|
27
|
+
throw invalidRequest('creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999');
|
|
28
28
|
}
|
|
29
29
|
const date = new Date(createdAtMs);
|
|
30
30
|
return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
|
|
@@ -38,12 +38,12 @@ export function last(count, unit, nowMs = Date.now()) {
|
|
|
38
38
|
!Number.isSafeInteger(nowMs) ||
|
|
39
39
|
nowMs < 0 ||
|
|
40
40
|
nowMs > MAX_TIME_BUCKET_MS) {
|
|
41
|
-
throw
|
|
41
|
+
throw invalidRequest('last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999');
|
|
42
42
|
}
|
|
43
43
|
const date = new Date(nowMs);
|
|
44
44
|
const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
|
|
45
45
|
if (current - (count - 1) < 1970 * 12) {
|
|
46
|
-
throw
|
|
46
|
+
throw invalidRequest('last requires every returned UTC month to fall from 1970 through 9999');
|
|
47
47
|
}
|
|
48
48
|
const units = [];
|
|
49
49
|
for (let offset = count - 1; offset >= 0; offset -= 1) {
|
package/dist/worker-entry.js
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
import { SyncClient } from './client.js';
|
|
17
17
|
import { withClientDiagnosticsHost, } from './diagnostics.js';
|
|
18
18
|
import { encryptionConfigFromKeyring } from './encryption.js';
|
|
19
|
-
import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
|
|
19
|
+
import { classifySqliteFailure, ClientSyncError, STORAGE_BUSY_CODE, } from './errors.js';
|
|
20
20
|
import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
|
|
21
21
|
import { openPersistentWasmDatabase } from './wasm-database.js';
|
|
22
22
|
import { WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
23
|
-
function toErrorShape(
|
|
23
|
+
function toErrorShape(caught) {
|
|
24
|
+
const error = classifySqliteFailure(caught).error;
|
|
24
25
|
if (error instanceof ClientSyncError) {
|
|
25
26
|
return {
|
|
26
27
|
code: error.code,
|
|
@@ -249,6 +250,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
249
250
|
},
|
|
250
251
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
251
252
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
253
|
+
...(config.retainFailedCommits !== undefined
|
|
254
|
+
? { retainFailedCommits: config.retainFailedCommits }
|
|
255
|
+
: {}),
|
|
252
256
|
...(config.realtimePolicy !== undefined
|
|
253
257
|
? { realtimePolicy: config.realtimePolicy }
|
|
254
258
|
: {}),
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export interface SyncClientHandleConfig {
|
|
|
87
87
|
readonly securityPreflight?: boolean;
|
|
88
88
|
readonly clientId?: string;
|
|
89
89
|
readonly limits?: SyncClientLimits;
|
|
90
|
+
readonly retainFailedCommits?: boolean;
|
|
90
91
|
/**
|
|
91
92
|
* SPEC §8.8 realtime policy for the worker-owned core, forwarded into
|
|
92
93
|
* `WorkerInitConfig`. Absent ⇒ `optional`.
|
package/dist/worker-host.js
CHANGED
|
@@ -545,6 +545,9 @@ function buildInitConfig(config, headers = config.headers) {
|
|
|
545
545
|
: {}),
|
|
546
546
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
547
547
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
548
|
+
...(config.retainFailedCommits !== undefined
|
|
549
|
+
? { retainFailedCommits: config.retainFailedCommits }
|
|
550
|
+
: {}),
|
|
548
551
|
...(config.realtimePolicy !== undefined
|
|
549
552
|
? { realtimePolicy: config.realtimePolicy }
|
|
550
553
|
: {}),
|
|
@@ -80,6 +80,7 @@ export interface WorkerInitConfig {
|
|
|
80
80
|
readonly securityPreflight?: boolean;
|
|
81
81
|
readonly clientId?: string;
|
|
82
82
|
readonly limits?: SyncClientLimits;
|
|
83
|
+
readonly retainFailedCommits?: boolean;
|
|
83
84
|
/**
|
|
84
85
|
* SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
|
|
85
86
|
* Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.9",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.9"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.9",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|