@syncular/client 0.20.0 → 0.21.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 +22 -5
- package/dist/apply.d.ts +14 -7
- package/dist/apply.js +75 -53
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +10 -10
- package/dist/client.js +162 -64
- package/dist/database.d.ts +3 -2
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +4 -2
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/outbox.d.ts +5 -5
- package/dist/outbox.js +5 -7
- package/dist/outcomes.d.ts +6 -0
- package/dist/outcomes.js +2 -0
- package/dist/reactive-store.js +49 -8
- package/dist/remote.js +40 -6
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +19 -5
- package/dist/wasm-database.js +3 -3
- package/package.json +3 -3
- package/src/apply.ts +86 -60
- package/src/bun-database.ts +6 -2
- package/src/client.ts +195 -72
- package/src/database.ts +7 -2
- package/src/encryption.ts +13 -6
- package/src/node-database.ts +6 -2
- package/src/outbox.ts +12 -12
- package/src/outcomes.ts +14 -1
- package/src/reactive-store.ts +58 -8
- package/src/remote.ts +54 -7
- package/src/schema.ts +29 -5
- package/src/wasm-database.ts +7 -3
package/src/client.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
deleteLocalRow,
|
|
45
45
|
deleteScopedRows,
|
|
46
46
|
evictScopedRows,
|
|
47
|
+
yieldToHost,
|
|
47
48
|
upsertLocalRow,
|
|
48
49
|
} from './apply';
|
|
49
50
|
import {
|
|
@@ -157,6 +158,8 @@ import {
|
|
|
157
158
|
ensureLocalBookkeepingSchema,
|
|
158
159
|
ensureLocalSyncedSchema,
|
|
159
160
|
fromSqlValue,
|
|
161
|
+
coerceSqlRepresentation,
|
|
162
|
+
type JsonRowValue,
|
|
160
163
|
jsonToRowValue,
|
|
161
164
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
162
165
|
normalizeRecordKeys,
|
|
@@ -286,8 +289,8 @@ export interface SyncClientLimits {
|
|
|
286
289
|
readonly maxSnapshotPages?: number;
|
|
287
290
|
/**
|
|
288
291
|
* §4.2 accept bitmask; defaults to inline + external rows (0b0011)
|
|
289
|
-
* plus
|
|
290
|
-
*
|
|
292
|
+
* plus SQLite images and signed URLs when supported. Both image chunks
|
|
293
|
+
* and rows blocks commit before yielding to local reads (§7.7).
|
|
291
294
|
*/
|
|
292
295
|
readonly accept?: number;
|
|
293
296
|
/**
|
|
@@ -748,6 +751,7 @@ export class SyncClient {
|
|
|
748
751
|
const startupWork =
|
|
749
752
|
this.#schemaFloor === undefined &&
|
|
750
753
|
(countOutbox(this.#db) > 0 ||
|
|
754
|
+
loadPendingEvictions(this.#db).length > 0 ||
|
|
751
755
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
752
756
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
753
757
|
this.#needsPull = true;
|
|
@@ -1820,23 +1824,17 @@ export class SyncClient {
|
|
|
1820
1824
|
socket.send(encodePresencePublish(scopeKey, doc));
|
|
1821
1825
|
}
|
|
1822
1826
|
|
|
1823
|
-
/**
|
|
1824
|
-
* §4.2 accept mask: the configured override, or the rows baseline plus
|
|
1825
|
-
* bit 2 when the backend can import sqlite images (§5.3) and a segment
|
|
1826
|
-
* downloader exists (sqlite segments are never inline, §5.7), plus
|
|
1827
|
-
* bit 3 when the downloader exposes a direct URL fetch (§5.4
|
|
1828
|
-
* capability negotiation).
|
|
1829
|
-
*/
|
|
1827
|
+
/** Advertise supported segment formats; all imports yield between commits. */
|
|
1830
1828
|
#acceptMask(): number {
|
|
1831
1829
|
const configured = this.#config.limits?.accept;
|
|
1832
1830
|
if (configured !== undefined) return configured;
|
|
1833
1831
|
const segments = this.#config.segments;
|
|
1834
|
-
const sqliteCapable =
|
|
1835
|
-
typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
|
|
1836
1832
|
const urlCapable = typeof segments?.fetchUrl === 'function';
|
|
1837
1833
|
return (
|
|
1838
1834
|
ACCEPT_ROWS_BASELINE |
|
|
1839
|
-
(
|
|
1835
|
+
(typeof this.#db.withSqliteImage === 'function' && segments !== undefined
|
|
1836
|
+
? ACCEPT_SQLITE
|
|
1837
|
+
: 0) |
|
|
1840
1838
|
(urlCapable ? ACCEPT_SIGNED_URLS : 0)
|
|
1841
1839
|
);
|
|
1842
1840
|
}
|
|
@@ -1909,8 +1907,8 @@ export class SyncClient {
|
|
|
1909
1907
|
* Set the live window units for a base (§4.8): a value-sharded family of
|
|
1910
1908
|
* subscriptions, one per unit. Computes the diff against the registry —
|
|
1911
1909
|
* added units get fresh subscriptions (image-lane bootstrap on the next
|
|
1912
|
-
* sync); removed units are unsubscribed
|
|
1913
|
-
* transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1910
|
+
* sync); removed units are unsubscribed with their first eviction chunk
|
|
1911
|
+
* in one local transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1914
1912
|
* no-op. Re-entry (a unit removed then re-added) cancels any deferred
|
|
1915
1913
|
* eviction and fresh-bootstraps.
|
|
1916
1914
|
*
|
|
@@ -1975,7 +1973,7 @@ export class SyncClient {
|
|
|
1975
1973
|
// Shrink: units live but not wanted → unsubscribe fused with eviction.
|
|
1976
1974
|
for (const { unit, subId } of live) {
|
|
1977
1975
|
if (wanted.has(unit)) continue;
|
|
1978
|
-
this.#evictUnit(baseKey, base, unit, subId);
|
|
1976
|
+
await this.#evictUnit(baseKey, base, unit, subId);
|
|
1979
1977
|
changed = true;
|
|
1980
1978
|
}
|
|
1981
1979
|
});
|
|
@@ -2018,8 +2016,8 @@ export class SyncClient {
|
|
|
2018
2016
|
}
|
|
2019
2017
|
|
|
2020
2018
|
/**
|
|
2021
|
-
* §4.8 E1–E4: evict one departing unit
|
|
2022
|
-
*
|
|
2019
|
+
* §4.8 E1–E4: evict one departing unit in committed chunks, fusing
|
|
2020
|
+
* its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
|
|
2023
2021
|
* pending outbox commit (E1); if any pin remains, records a deferred
|
|
2024
2022
|
* eviction retried on the next outbox drain. Discards the subscription's
|
|
2025
2023
|
* cursor/resume/effective-echo (E3) and its version state with the rows
|
|
@@ -2027,24 +2025,26 @@ export class SyncClient {
|
|
|
2027
2025
|
* with no local scope-column mapping, surfaces a configuration error and
|
|
2028
2026
|
* evicts nothing (§4.8/§3.3).
|
|
2029
2027
|
*/
|
|
2030
|
-
#evictUnit(
|
|
2028
|
+
async #evictUnit(
|
|
2031
2029
|
baseKey: string,
|
|
2032
2030
|
base: WindowBase,
|
|
2033
2031
|
unit: string,
|
|
2034
2032
|
subId: string,
|
|
2035
|
-
): void {
|
|
2033
|
+
): Promise<void> {
|
|
2036
2034
|
const table = this.#table(base.table);
|
|
2037
2035
|
const sub = getSubscription(this.#db, subId);
|
|
2038
2036
|
// The rows a unit holds live under its LAST effective scopes if it ever
|
|
2039
2037
|
// synced; before first sync, the requested unit scopes are the match.
|
|
2040
2038
|
const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
|
|
2041
2039
|
const pinned = this.#pinnedRowIds(base.table);
|
|
2040
|
+
let remaining = false;
|
|
2042
2041
|
this.#applyBatch((batch) => {
|
|
2043
2042
|
this.#db.transaction(() => {
|
|
2044
|
-
const
|
|
2043
|
+
const result = evictScopedRows(this.#db, table, effective, pinned);
|
|
2044
|
+
remaining = result.remaining;
|
|
2045
2045
|
deleteWindowUnit(this.#db, baseKey, unit);
|
|
2046
2046
|
deleteSubscription(this.#db, subId);
|
|
2047
|
-
if (deferred) {
|
|
2047
|
+
if (result.deferred) {
|
|
2048
2048
|
savePendingEviction(this.#db, subId, base.table, effective);
|
|
2049
2049
|
} else {
|
|
2050
2050
|
deletePendingEviction(this.#db, subId);
|
|
@@ -2055,6 +2055,10 @@ export class SyncClient {
|
|
|
2055
2055
|
this.#needsPull = true;
|
|
2056
2056
|
batch.status();
|
|
2057
2057
|
});
|
|
2058
|
+
if (remaining) {
|
|
2059
|
+
await yieldToHost();
|
|
2060
|
+
await this.#drainPendingEvictions();
|
|
2061
|
+
}
|
|
2058
2062
|
}
|
|
2059
2063
|
|
|
2060
2064
|
/**
|
|
@@ -2062,7 +2066,7 @@ export class SyncClient {
|
|
|
2062
2066
|
* unit's rows are removed once no pending commit references them; a unit
|
|
2063
2067
|
* that re-entered the window in the meantime has no pending record left.
|
|
2064
2068
|
*/
|
|
2065
|
-
#drainPendingEvictions(): void {
|
|
2069
|
+
async #drainPendingEvictions(): Promise<void> {
|
|
2066
2070
|
const pending = loadPendingEvictions(this.#db);
|
|
2067
2071
|
if (pending.length === 0) return;
|
|
2068
2072
|
for (const entry of pending) {
|
|
@@ -2071,15 +2075,30 @@ export class SyncClient {
|
|
|
2071
2075
|
deletePendingEviction(this.#db, entry.subId);
|
|
2072
2076
|
continue;
|
|
2073
2077
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2078
|
+
let remaining: boolean;
|
|
2079
|
+
do {
|
|
2080
|
+
if (
|
|
2081
|
+
!loadPendingEvictions(this.#db).some(
|
|
2082
|
+
(item) => item.subId === entry.subId,
|
|
2083
|
+
)
|
|
2084
|
+
)
|
|
2085
|
+
break;
|
|
2086
|
+
// Local writes may add pins while the worker yields between chunks.
|
|
2087
|
+
const pinned = this.#pinnedRowIds(entry.table);
|
|
2088
|
+
remaining = false;
|
|
2089
|
+
this.#applyBatch((batch) => {
|
|
2090
|
+
const result = evictScopedRows(
|
|
2091
|
+
this.#db,
|
|
2092
|
+
table,
|
|
2093
|
+
entry.effective,
|
|
2094
|
+
pinned,
|
|
2095
|
+
);
|
|
2096
|
+
remaining = result.remaining;
|
|
2097
|
+
if (!result.deferred) deletePendingEviction(this.#db, entry.subId);
|
|
2098
|
+
batch.scopeMap(table, entry.effective);
|
|
2080
2099
|
});
|
|
2081
|
-
|
|
2082
|
-
});
|
|
2100
|
+
if (remaining) await yieldToHost();
|
|
2101
|
+
} while (remaining);
|
|
2083
2102
|
}
|
|
2084
2103
|
}
|
|
2085
2104
|
|
|
@@ -2110,11 +2129,11 @@ export class SyncClient {
|
|
|
2110
2129
|
|
|
2111
2130
|
#recordMutations(
|
|
2112
2131
|
mutations: readonly MutationInput[],
|
|
2113
|
-
|
|
2132
|
+
partial = false,
|
|
2114
2133
|
): string {
|
|
2115
2134
|
this.#requireActive();
|
|
2116
2135
|
const clientCommitId = crypto.randomUUID();
|
|
2117
|
-
const operations: OutboxOperation[] = mutations.map((mutation
|
|
2136
|
+
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
2118
2137
|
const table = this.#table(mutation.table);
|
|
2119
2138
|
if (mutation.op === 'delete') {
|
|
2120
2139
|
return {
|
|
@@ -2126,6 +2145,55 @@ export class SyncClient {
|
|
|
2126
2145
|
: {}),
|
|
2127
2146
|
};
|
|
2128
2147
|
}
|
|
2148
|
+
if (partial) {
|
|
2149
|
+
// §6.7 patch: the values map IS the presence set — the primary key
|
|
2150
|
+
// plus the supplied non-scope columns. Absent columns keep their
|
|
2151
|
+
// stored value; the overlay and the server both apply only the
|
|
2152
|
+
// present ones.
|
|
2153
|
+
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2154
|
+
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2155
|
+
const json: Record<string, JsonRowValue> = {};
|
|
2156
|
+
for (const column of table.columns) {
|
|
2157
|
+
if (!normalized.has(column.name)) continue;
|
|
2158
|
+
if (scopeColumns.has(column.name)) {
|
|
2159
|
+
throw new ClientSyncError(
|
|
2160
|
+
'sync.invalid_request',
|
|
2161
|
+
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
const value = normalized.get(column.name);
|
|
2165
|
+
if (value === undefined || value === null) {
|
|
2166
|
+
if (!column.nullable) {
|
|
2167
|
+
throw new ClientSyncError(
|
|
2168
|
+
'sync.invalid_request',
|
|
2169
|
+
`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1)`,
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
json[column.name] = null;
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
json[column.name] = rowValueToJson(
|
|
2176
|
+
coerceSqlRepresentation(column, value) as RowValue,
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2180
|
+
const pkValue = json[pkColumn.name];
|
|
2181
|
+
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2182
|
+
throw new ClientSyncError(
|
|
2183
|
+
'sync.invalid_request',
|
|
2184
|
+
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
table: mutation.table,
|
|
2189
|
+
rowId: pkValue,
|
|
2190
|
+
op: 'upsert',
|
|
2191
|
+
...(mutation.baseVersion !== undefined
|
|
2192
|
+
? { baseVersion: mutation.baseVersion }
|
|
2193
|
+
: {}),
|
|
2194
|
+
values: json,
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2129
2197
|
const values = recordToRowValues(table, mutation.values);
|
|
2130
2198
|
const pkValue = values[table.primaryKeyIndex];
|
|
2131
2199
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
@@ -2146,9 +2214,6 @@ export class SyncClient {
|
|
|
2146
2214
|
? { baseVersion: mutation.baseVersion }
|
|
2147
2215
|
: {}),
|
|
2148
2216
|
values: json,
|
|
2149
|
-
...(changedFieldsByIndex[index] !== undefined
|
|
2150
|
-
? { changedFields: [...(changedFieldsByIndex[index] ?? [])] }
|
|
2151
|
-
: {}),
|
|
2152
2217
|
};
|
|
2153
2218
|
});
|
|
2154
2219
|
this.#applyBatch((batch) => {
|
|
@@ -2201,12 +2266,12 @@ export class SyncClient {
|
|
|
2201
2266
|
}
|
|
2202
2267
|
|
|
2203
2268
|
/**
|
|
2204
|
-
* Partial
|
|
2205
|
-
*
|
|
2206
|
-
*
|
|
2207
|
-
*
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2269
|
+
* Partial update over the §6.1 sparse wire: record one upsert whose
|
|
2270
|
+
* presence set is the primary key plus the supplied non-scope columns.
|
|
2271
|
+
* `partial` keys follow the same two-casing rule as mutation values
|
|
2272
|
+
* (snake_case or camelCase). Absent columns keep their stored value on
|
|
2273
|
+
* the server and in the local overlay; when the local row is absent the
|
|
2274
|
+
* overlay leaves it absent (§7.1) and the server answers per §5.2.
|
|
2210
2275
|
*/
|
|
2211
2276
|
patch(
|
|
2212
2277
|
table: string,
|
|
@@ -2217,37 +2282,29 @@ export class SyncClient {
|
|
|
2217
2282
|
this.#requireActive();
|
|
2218
2283
|
const compiled = this.#table(table);
|
|
2219
2284
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
);
|
|
2224
|
-
const row = rows[0];
|
|
2225
|
-
if (row === undefined) {
|
|
2285
|
+
const normalized = normalizeRecordKeys(compiled, partial);
|
|
2286
|
+
const pkOverride = normalized.get(pkColumn.name);
|
|
2287
|
+
if (pkOverride !== undefined && pkOverride !== rowId) {
|
|
2226
2288
|
throw new ClientSyncError(
|
|
2227
2289
|
'sync.invalid_request',
|
|
2228
|
-
`table ${compiled.name}:
|
|
2290
|
+
`table ${compiled.name}: patch cannot change the primary key`,
|
|
2229
2291
|
);
|
|
2230
2292
|
}
|
|
2231
|
-
const record: Record<string, unknown> = {};
|
|
2232
|
-
for (const column of compiled.columns as readonly RowColumn[]) {
|
|
2233
|
-
record[column.name] = fromSqlValue(column, row[column.name] ?? null);
|
|
2234
|
-
}
|
|
2235
|
-
const normalizedPartial = normalizeRecordKeys(compiled, partial);
|
|
2236
|
-
for (const [name, value] of normalizedPartial) {
|
|
2237
|
-
record[name] = value;
|
|
2238
|
-
}
|
|
2239
2293
|
return this.#recordMutations(
|
|
2240
2294
|
[
|
|
2241
2295
|
{
|
|
2242
2296
|
table,
|
|
2243
2297
|
op: 'upsert',
|
|
2244
|
-
values:
|
|
2298
|
+
values: {
|
|
2299
|
+
...Object.fromEntries(normalized),
|
|
2300
|
+
[pkColumn.name]: rowId,
|
|
2301
|
+
},
|
|
2245
2302
|
...(options?.baseVersion !== undefined
|
|
2246
2303
|
? { baseVersion: options.baseVersion }
|
|
2247
2304
|
: {}),
|
|
2248
2305
|
},
|
|
2249
2306
|
],
|
|
2250
|
-
|
|
2307
|
+
true,
|
|
2251
2308
|
);
|
|
2252
2309
|
}
|
|
2253
2310
|
|
|
@@ -2753,6 +2810,7 @@ export class SyncClient {
|
|
|
2753
2810
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
2754
2811
|
this.#setSyncNeeded(false);
|
|
2755
2812
|
try {
|
|
2813
|
+
await this.#drainPendingEvictions();
|
|
2756
2814
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2757
2815
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2758
2816
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -2830,7 +2888,7 @@ export class SyncClient {
|
|
|
2830
2888
|
);
|
|
2831
2889
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
2832
2890
|
// a shrunk window unit — retry any deferred evictions now.
|
|
2833
|
-
this.#drainPendingEvictions();
|
|
2891
|
+
await this.#drainPendingEvictions();
|
|
2834
2892
|
this.#progress.update(
|
|
2835
2893
|
summary.failed.length > 0
|
|
2836
2894
|
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
@@ -2879,10 +2937,27 @@ export class SyncClient {
|
|
|
2879
2937
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
2880
2938
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2881
2939
|
*/
|
|
2882
|
-
async syncUntilIdle(maxRounds
|
|
2940
|
+
async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
|
|
2941
|
+
const budget = maxRounds ?? 20;
|
|
2883
2942
|
let last: SyncSummary | undefined;
|
|
2884
|
-
for (let round = 0; round <
|
|
2943
|
+
for (let round = 0; round < budget; round++) {
|
|
2944
|
+
const before = JSON.stringify(
|
|
2945
|
+
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
2946
|
+
);
|
|
2885
2947
|
last = await this.sync();
|
|
2948
|
+
if (
|
|
2949
|
+
maxRounds === undefined &&
|
|
2950
|
+
last.segmentRowsApplied > 0 &&
|
|
2951
|
+
last.bootstrapping.length > 0 &&
|
|
2952
|
+
before !==
|
|
2953
|
+
JSON.stringify(
|
|
2954
|
+
loadSubscriptions(this.#db).map((sub) => [
|
|
2955
|
+
sub.id,
|
|
2956
|
+
sub.bootstrapState,
|
|
2957
|
+
]),
|
|
2958
|
+
)
|
|
2959
|
+
)
|
|
2960
|
+
round = -1;
|
|
2886
2961
|
if (last.schemaFloor !== undefined) return last;
|
|
2887
2962
|
if (
|
|
2888
2963
|
last.commitsApplied === 0 &&
|
|
@@ -2897,7 +2972,7 @@ export class SyncClient {
|
|
|
2897
2972
|
}
|
|
2898
2973
|
throw new ClientSyncError(
|
|
2899
2974
|
'sync.invalid_request',
|
|
2900
|
-
|
|
2975
|
+
'sync did not reach idle within the round budget',
|
|
2901
2976
|
);
|
|
2902
2977
|
}
|
|
2903
2978
|
|
|
@@ -3428,6 +3503,7 @@ export class SyncClient {
|
|
|
3428
3503
|
segment,
|
|
3429
3504
|
{
|
|
3430
3505
|
clearFirst,
|
|
3506
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3431
3507
|
onProgress: (rowsProcessed) =>
|
|
3432
3508
|
this.#progress.update({ rowsProcessed }),
|
|
3433
3509
|
effective,
|
|
@@ -3440,7 +3516,9 @@ export class SyncClient {
|
|
|
3440
3516
|
) {
|
|
3441
3517
|
batch.table(table.name);
|
|
3442
3518
|
}
|
|
3443
|
-
|
|
3519
|
+
const result = fn();
|
|
3520
|
+
this.#replayOutbox();
|
|
3521
|
+
return result;
|
|
3444
3522
|
}),
|
|
3445
3523
|
},
|
|
3446
3524
|
this.#encryption,
|
|
@@ -3499,6 +3577,7 @@ export class SyncClient {
|
|
|
3499
3577
|
},
|
|
3500
3578
|
{
|
|
3501
3579
|
clearFirst,
|
|
3580
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3502
3581
|
onProgress: (rowsProcessed) =>
|
|
3503
3582
|
this.#progress.update({ rowsProcessed }),
|
|
3504
3583
|
effective,
|
|
@@ -3511,7 +3590,9 @@ export class SyncClient {
|
|
|
3511
3590
|
) {
|
|
3512
3591
|
batch.table(table.name);
|
|
3513
3592
|
}
|
|
3514
|
-
|
|
3593
|
+
const result = fn();
|
|
3594
|
+
this.#replayOutbox();
|
|
3595
|
+
return result;
|
|
3515
3596
|
}),
|
|
3516
3597
|
},
|
|
3517
3598
|
),
|
|
@@ -3530,6 +3611,7 @@ export class SyncClient {
|
|
|
3530
3611
|
segment,
|
|
3531
3612
|
{
|
|
3532
3613
|
clearFirst,
|
|
3614
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3533
3615
|
onProgress: (rowsProcessed) =>
|
|
3534
3616
|
this.#progress.update({ rowsProcessed }),
|
|
3535
3617
|
effective,
|
|
@@ -3542,7 +3624,9 @@ export class SyncClient {
|
|
|
3542
3624
|
) {
|
|
3543
3625
|
batch.table(table.name);
|
|
3544
3626
|
}
|
|
3545
|
-
|
|
3627
|
+
const result = fn();
|
|
3628
|
+
this.#replayOutbox();
|
|
3629
|
+
return result;
|
|
3546
3630
|
}),
|
|
3547
3631
|
},
|
|
3548
3632
|
this.#encryption,
|
|
@@ -3691,6 +3775,10 @@ export class SyncClient {
|
|
|
3691
3775
|
message: result.message,
|
|
3692
3776
|
serverVersion: result.serverVersion,
|
|
3693
3777
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3778
|
+
conflictColumns: this.#decodeConflictColumns(
|
|
3779
|
+
operation?.table,
|
|
3780
|
+
result.conflictColumns,
|
|
3781
|
+
),
|
|
3694
3782
|
...(operation !== undefined ? { operation } : {}),
|
|
3695
3783
|
};
|
|
3696
3784
|
this.#conflicts.push(conflict);
|
|
@@ -3760,6 +3848,26 @@ export class SyncClient {
|
|
|
3760
3848
|
return record;
|
|
3761
3849
|
}
|
|
3762
3850
|
|
|
3851
|
+
/**
|
|
3852
|
+
* §6.3 `conflictColumns`: decode the presence-layout bitmap into the names
|
|
3853
|
+
* of the present columns whose `column_version` exceeded `baseVersion`. A
|
|
3854
|
+
* custom merge recomputes exactly these columns (§6.5).
|
|
3855
|
+
*/
|
|
3856
|
+
#decodeConflictColumns(
|
|
3857
|
+
tableName: string | undefined,
|
|
3858
|
+
bitmap: Uint8Array,
|
|
3859
|
+
): readonly string[] {
|
|
3860
|
+
if (tableName === undefined) return [];
|
|
3861
|
+
const table = this.#schema.tables.get(tableName);
|
|
3862
|
+
if (table === undefined) return [];
|
|
3863
|
+
const names: string[] = [];
|
|
3864
|
+
table.columns.forEach((column, index) => {
|
|
3865
|
+
const byte = bitmap[index >> 3] ?? 0;
|
|
3866
|
+
if (((byte >> (index & 7)) & 1) === 1) names.push(column.name);
|
|
3867
|
+
});
|
|
3868
|
+
return names;
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3763
3871
|
async #applyCommit(
|
|
3764
3872
|
frame: CommitFrame,
|
|
3765
3873
|
summary: MutableSummary,
|
|
@@ -4274,9 +4382,26 @@ export class SyncClient {
|
|
|
4274
4382
|
deleteLocalRow(this.#db, table, op.rowId);
|
|
4275
4383
|
continue;
|
|
4276
4384
|
}
|
|
4385
|
+
// §7.1 overlay: apply the operation's PRESENT columns over the current
|
|
4386
|
+
// local row. A partial operation over an absent local row leaves it
|
|
4387
|
+
// absent — the server answers it with sync.row_deleted or
|
|
4388
|
+
// sync.row_missing, and the local mirror never invents a base.
|
|
4389
|
+
const opValues = op.values ?? {};
|
|
4390
|
+
const full = table.columns.every((column) => column.name in opValues);
|
|
4391
|
+
const local = this.#db.query(
|
|
4392
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4393
|
+
[op.rowId],
|
|
4394
|
+
)[0];
|
|
4395
|
+
if (local === undefined && !full) {
|
|
4396
|
+
if (batch !== undefined && !precise) batch.table(op.table);
|
|
4397
|
+
continue;
|
|
4398
|
+
}
|
|
4277
4399
|
const values = table.columns.map((column) => {
|
|
4278
|
-
const value =
|
|
4279
|
-
|
|
4400
|
+
const value = opValues[column.name];
|
|
4401
|
+
if (value !== undefined) return jsonToRowValue(value);
|
|
4402
|
+
return local === undefined
|
|
4403
|
+
? null
|
|
4404
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
4280
4405
|
});
|
|
4281
4406
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
4282
4407
|
if (batch !== undefined) {
|
|
@@ -4291,12 +4416,10 @@ export class SyncClient {
|
|
|
4291
4416
|
}
|
|
4292
4417
|
if (!precise) batch.table(table.name);
|
|
4293
4418
|
}
|
|
4294
|
-
const existing = this.#db.query(
|
|
4295
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4296
|
-
[op.rowId],
|
|
4297
|
-
)[0];
|
|
4298
4419
|
const version =
|
|
4299
|
-
|
|
4420
|
+
local === undefined
|
|
4421
|
+
? OPTIMISTIC_VERSION
|
|
4422
|
+
: (local[SYNC_VERSION_COLUMN] as number);
|
|
4300
4423
|
upsertLocalRow(this.#db, table, values, version);
|
|
4301
4424
|
}
|
|
4302
4425
|
}
|
package/src/database.ts
CHANGED
|
@@ -22,11 +22,16 @@ export interface ClientDatabase {
|
|
|
22
22
|
/**
|
|
23
23
|
* Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
|
|
24
24
|
* attached read-only database schema named `alias` for the duration of
|
|
25
|
-
* `fn`,
|
|
25
|
+
* `fn`, awaiting its result before detaching. No transaction spans its awaits.
|
|
26
|
+
* Presence of this method is what makes the client
|
|
26
27
|
* advertise `accept` bit 2 (§4.2). MUST be called outside any open
|
|
27
28
|
* transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
|
|
28
29
|
*/
|
|
29
|
-
withSqliteImage?<T>(
|
|
30
|
+
withSqliteImage?<T>(
|
|
31
|
+
bytes: Uint8Array,
|
|
32
|
+
alias: string,
|
|
33
|
+
fn: () => T | Promise<T>,
|
|
34
|
+
): Promise<T>;
|
|
30
35
|
close(): void;
|
|
31
36
|
}
|
|
32
37
|
|
package/src/encryption.ts
CHANGED
|
@@ -142,15 +142,22 @@ function declaredTypeOf(column: RowColumn): DeclaredType {
|
|
|
142
142
|
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
143
143
|
* unencrypted (§5.11).
|
|
144
144
|
*/
|
|
145
|
-
export async function encryptRowValues(
|
|
145
|
+
export async function encryptRowValues<T extends RowValue | undefined>(
|
|
146
146
|
config: EncryptionConfig,
|
|
147
147
|
table: CompiledClientTable,
|
|
148
148
|
rowId: string,
|
|
149
|
-
values: readonly
|
|
150
|
-
): Promise<
|
|
149
|
+
values: readonly T[],
|
|
150
|
+
): Promise<T[]> {
|
|
151
151
|
if (!table.hasEncryptedColumns) return values.slice();
|
|
152
152
|
const out = values.slice();
|
|
153
|
-
|
|
153
|
+
// A sparse operation's absent columns are densified to null for key
|
|
154
|
+
// selection only; the encryption loop leaves them untouched.
|
|
155
|
+
const keyId = encryptionKeyId(
|
|
156
|
+
config,
|
|
157
|
+
table,
|
|
158
|
+
rowId,
|
|
159
|
+
values.map((value) => value ?? null),
|
|
160
|
+
);
|
|
154
161
|
for (let i = 0; i < table.columns.length; i++) {
|
|
155
162
|
const column = table.columns[i];
|
|
156
163
|
if (column === undefined || !column.encrypted) continue;
|
|
@@ -162,13 +169,13 @@ export async function encryptRowValues(
|
|
|
162
169
|
`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
|
|
163
170
|
);
|
|
164
171
|
}
|
|
165
|
-
out[i] = await encryptValue(
|
|
172
|
+
out[i] = (await encryptValue(
|
|
166
173
|
declaredTypeOf(column),
|
|
167
174
|
value as PlainValue,
|
|
168
175
|
keyId,
|
|
169
176
|
key,
|
|
170
177
|
config.nonceSource,
|
|
171
|
-
);
|
|
178
|
+
)) as T;
|
|
172
179
|
}
|
|
173
180
|
return out;
|
|
174
181
|
}
|
package/src/node-database.ts
CHANGED
|
@@ -63,7 +63,11 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** §5.3 image import through a private file attached for one callback. */
|
|
66
|
-
withSqliteImage<T>(
|
|
66
|
+
async withSqliteImage<T>(
|
|
67
|
+
bytes: Uint8Array,
|
|
68
|
+
alias: string,
|
|
69
|
+
fn: () => T | Promise<T>,
|
|
70
|
+
): Promise<T> {
|
|
67
71
|
assertImageAlias(alias);
|
|
68
72
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
69
73
|
const path = join(dir, 'segment.db');
|
|
@@ -71,7 +75,7 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
71
75
|
writeFileSync(path, bytes);
|
|
72
76
|
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
73
77
|
try {
|
|
74
|
-
return fn();
|
|
78
|
+
return await fn();
|
|
75
79
|
} finally {
|
|
76
80
|
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
|
77
81
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* upgrade to N+1 by re-encoding.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
encodeSparseRow,
|
|
10
10
|
type PushCommitFrame,
|
|
11
11
|
type PushOperation,
|
|
12
12
|
type ScopeMap,
|
|
13
|
+
type SparseRowValue,
|
|
13
14
|
} from '@syncular/core';
|
|
14
15
|
import type { ClientDatabase, SqlRow } from './database';
|
|
15
16
|
import type { EncryptionConfig } from './encryption';
|
|
@@ -27,13 +28,13 @@ export interface OutboxOperation {
|
|
|
27
28
|
readonly op: 'upsert' | 'delete';
|
|
28
29
|
/** Optimistic-concurrency token (§6.2); absent = last-write-wins. */
|
|
29
30
|
readonly baseVersion?: number;
|
|
30
|
-
/** Full-row values keyed by column name; present iff `op` is `upsert`. */
|
|
31
|
-
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* The PRESENT columns keyed by column name; present iff `op` is `upsert`.
|
|
33
|
+
* A key the operation writes carries its value; a column the operation
|
|
34
|
+
* leaves unchanged has no key (§6.1 sparse rows, §7.2.1: intent is the
|
|
35
|
+
* presence set — `mutate` marks every column, `patch` the supplied ones).
|
|
35
36
|
*/
|
|
36
|
-
readonly
|
|
37
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export interface OutboxCommit {
|
|
@@ -212,10 +213,10 @@ export class OutboxEncodeError extends ClientSyncError {
|
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
function
|
|
216
|
+
function sparseValues(
|
|
216
217
|
table: CompiledClientTable,
|
|
217
218
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
218
|
-
) {
|
|
219
|
+
): SparseRowValue[] {
|
|
219
220
|
// Any persisted key that is not a column of the CURRENT schema means the
|
|
220
221
|
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
221
222
|
for (const key of Object.keys(values)) {
|
|
@@ -227,8 +228,7 @@ function orderedValues(
|
|
|
227
228
|
}
|
|
228
229
|
return table.columns.map((column) => {
|
|
229
230
|
const value = values[column.name];
|
|
230
|
-
|
|
231
|
-
return jsonToRowValue(value);
|
|
231
|
+
return value === undefined ? undefined : jsonToRowValue(value);
|
|
232
232
|
});
|
|
233
233
|
}
|
|
234
234
|
|
|
@@ -270,7 +270,7 @@ export async function encodeOutboxCommit(
|
|
|
270
270
|
`outbox upsert on ${op.table}/${op.rowId} has no values`,
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
|
-
let values =
|
|
273
|
+
let values = sparseValues(table, op.values);
|
|
274
274
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
275
275
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
276
276
|
const { encryptRowValues } = await import('./encryption');
|
|
@@ -281,7 +281,7 @@ export async function encodeOutboxCommit(
|
|
|
281
281
|
rowId: op.rowId,
|
|
282
282
|
op: 'upsert',
|
|
283
283
|
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
|
|
284
|
-
payload:
|
|
284
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
285
285
|
});
|
|
286
286
|
}
|
|
287
287
|
return {
|