@syncular/client 0.21.1 → 0.22.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/dist/client.js +21 -3
- package/dist/reactive-store.js +34 -9
- package/dist/schema.js +11 -0
- package/package.json +3 -3
- package/src/client.ts +40 -6
- package/src/reactive-store.ts +36 -16
- package/src/schema.ts +15 -0
package/dist/client.js
CHANGED
|
@@ -1413,12 +1413,31 @@ export class SyncClient {
|
|
|
1413
1413
|
// present ones.
|
|
1414
1414
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
1415
1415
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1416
|
+
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1416
1417
|
const json = {};
|
|
1417
1418
|
for (const column of table.columns) {
|
|
1418
1419
|
if (!normalized.has(column.name))
|
|
1419
1420
|
continue;
|
|
1420
|
-
|
|
1421
|
-
|
|
1421
|
+
// §3.4 rule 5 / §6.2: scope columns are immutable on update. The
|
|
1422
|
+
// server accepts a present scope column whose value equals the
|
|
1423
|
+
// stored row and applies it as a no-op, so a decoded envelope
|
|
1424
|
+
// round-trips. The local row is the only value the client can
|
|
1425
|
+
// prove equality against; when the row is absent locally the
|
|
1426
|
+
// client cannot prove equality against the server's stored row
|
|
1427
|
+
// and fails closed. A primary key that is also a scope column
|
|
1428
|
+
// needs no stored-row read: the primary key in a sparse payload is
|
|
1429
|
+
// by construction the row id being patched (§6.1), so its value is
|
|
1430
|
+
// proven equal already.
|
|
1431
|
+
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
1432
|
+
const pkValue = normalized.get(pkColumn.name);
|
|
1433
|
+
const local = typeof pkValue === 'string' && pkValue.length > 0
|
|
1434
|
+
? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
|
|
1435
|
+
: undefined;
|
|
1436
|
+
if (local === undefined ||
|
|
1437
|
+
!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1438
|
+
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1439
|
+
}
|
|
1440
|
+
continue;
|
|
1422
1441
|
}
|
|
1423
1442
|
const value = normalized.get(column.name);
|
|
1424
1443
|
if (value === undefined || value === null) {
|
|
@@ -1430,7 +1449,6 @@ export class SyncClient {
|
|
|
1430
1449
|
}
|
|
1431
1450
|
json[column.name] = rowValueToJson(coerceSqlRepresentation(column, value));
|
|
1432
1451
|
}
|
|
1433
|
-
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1434
1452
|
const pkValue = json[pkColumn.name];
|
|
1435
1453
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
1436
1454
|
throw new ClientSyncError('sync.invalid_request', `table ${table.name}: upsert requires a non-empty string primary key`);
|
package/dist/reactive-store.js
CHANGED
|
@@ -82,15 +82,21 @@ function scheduleMicrotask(task) {
|
|
|
82
82
|
}
|
|
83
83
|
function batchMatches(batch, spec) {
|
|
84
84
|
for (const change of batch.tables) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
85
|
+
// A composed spec carries one dependency per query branch. Every
|
|
86
|
+
// dependency on the changed table is consulted: a scoped dependency
|
|
87
|
+
// that does not cover the change must not mask a sibling dependency
|
|
88
|
+
// on the same table that does.
|
|
89
|
+
for (const dependency of spec.dependencies) {
|
|
90
|
+
if (dependency.table !== change.table)
|
|
91
|
+
continue;
|
|
92
|
+
if (dependency.scopeKeys === undefined ||
|
|
93
|
+
change.scopeKeys === undefined) {
|
|
93
94
|
return true;
|
|
95
|
+
}
|
|
96
|
+
for (const key of dependency.scopeKeys) {
|
|
97
|
+
if (change.scopeKeys.has(key))
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
102
|
for (const change of batch.windows) {
|
|
@@ -268,8 +274,27 @@ class QueryEntry {
|
|
|
268
274
|
this.#onAvailabilityChange();
|
|
269
275
|
if (this.spec.claimCoverage !== false) {
|
|
270
276
|
const claims = [];
|
|
277
|
+
// A composed spec carries one coverage entry per query branch, and
|
|
278
|
+
// several branches can target the same base. One claim per distinct
|
|
279
|
+
// base covers the union of their units; a later entry for the same
|
|
280
|
+
// base does not replace the earlier one.
|
|
281
|
+
const unitsByBase = new Map();
|
|
271
282
|
for (const coverage of this.spec.coverage ?? []) {
|
|
272
|
-
|
|
283
|
+
const baseKey = windowBaseKey(coverage.base);
|
|
284
|
+
const existing = unitsByBase.get(baseKey);
|
|
285
|
+
if (existing === undefined) {
|
|
286
|
+
unitsByBase.set(baseKey, {
|
|
287
|
+
base: coverage.base,
|
|
288
|
+
units: new Set(coverage.units),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
for (const unit of coverage.units)
|
|
293
|
+
existing.units.add(unit);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const { base, units } of unitsByBase.values()) {
|
|
297
|
+
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
273
298
|
}
|
|
274
299
|
const generation = this.#generation;
|
|
275
300
|
this.#claimPending = claims.length > 0;
|
package/dist/schema.js
CHANGED
|
@@ -31,6 +31,17 @@ export function compileClientSchema(schema) {
|
|
|
31
31
|
if (primaryKeyIndex === undefined) {
|
|
32
32
|
throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
|
|
33
33
|
}
|
|
34
|
+
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
35
|
+
// string every implementation produces identically and local storage
|
|
36
|
+
// resolves without deferring to a SQLite build.
|
|
37
|
+
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
38
|
+
if (primaryKeyColumn !== undefined &&
|
|
39
|
+
primaryKeyColumn.type !== 'string' &&
|
|
40
|
+
primaryKeyColumn.type !== 'integer' &&
|
|
41
|
+
primaryKeyColumn.type !== 'boolean' &&
|
|
42
|
+
primaryKeyColumn.type !== 'json') {
|
|
43
|
+
throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`);
|
|
44
|
+
}
|
|
34
45
|
if (table.scopes.length === 0) {
|
|
35
46
|
throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
|
|
36
47
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.22.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.22.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -2153,14 +2153,49 @@ export class SyncClient {
|
|
|
2153
2153
|
// present ones.
|
|
2154
2154
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2155
2155
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2156
|
+
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2156
2157
|
const json: Record<string, JsonRowValue> = {};
|
|
2157
2158
|
for (const column of table.columns) {
|
|
2158
2159
|
if (!normalized.has(column.name)) continue;
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2160
|
+
// §3.4 rule 5 / §6.2: scope columns are immutable on update. The
|
|
2161
|
+
// server accepts a present scope column whose value equals the
|
|
2162
|
+
// stored row and applies it as a no-op, so a decoded envelope
|
|
2163
|
+
// round-trips. The local row is the only value the client can
|
|
2164
|
+
// prove equality against; when the row is absent locally the
|
|
2165
|
+
// client cannot prove equality against the server's stored row
|
|
2166
|
+
// and fails closed. A primary key that is also a scope column
|
|
2167
|
+
// needs no stored-row read: the primary key in a sparse payload is
|
|
2168
|
+
// by construction the row id being patched (§6.1), so its value is
|
|
2169
|
+
// proven equal already.
|
|
2170
|
+
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
2171
|
+
const pkValue = normalized.get(pkColumn.name);
|
|
2172
|
+
const local =
|
|
2173
|
+
typeof pkValue === 'string' && pkValue.length > 0
|
|
2174
|
+
? this.#db.query(
|
|
2175
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2176
|
+
[pkValue],
|
|
2177
|
+
)[0]
|
|
2178
|
+
: undefined;
|
|
2179
|
+
if (
|
|
2180
|
+
local === undefined ||
|
|
2181
|
+
!Object.is(
|
|
2182
|
+
rowValueToJson(
|
|
2183
|
+
fromSqlValue(column, local[column.name] ?? null),
|
|
2184
|
+
),
|
|
2185
|
+
rowValueToJson(
|
|
2186
|
+
coerceSqlRepresentation(
|
|
2187
|
+
column,
|
|
2188
|
+
normalized.get(column.name),
|
|
2189
|
+
) as RowValue,
|
|
2190
|
+
),
|
|
2191
|
+
)
|
|
2192
|
+
) {
|
|
2193
|
+
throw new ClientSyncError(
|
|
2194
|
+
'sync.invalid_request',
|
|
2195
|
+
`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
continue;
|
|
2164
2199
|
}
|
|
2165
2200
|
const value = normalized.get(column.name);
|
|
2166
2201
|
if (value === undefined || value === null) {
|
|
@@ -2177,7 +2212,6 @@ export class SyncClient {
|
|
|
2177
2212
|
coerceSqlRepresentation(column, value) as RowValue,
|
|
2178
2213
|
);
|
|
2179
2214
|
}
|
|
2180
|
-
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2181
2215
|
const pkValue = json[pkColumn.name];
|
|
2182
2216
|
if (typeof pkValue !== 'string' || pkValue.length === 0) {
|
|
2183
2217
|
throw new ClientSyncError(
|
package/src/reactive-store.ts
CHANGED
|
@@ -196,15 +196,21 @@ function batchMatches<Row>(
|
|
|
196
196
|
spec: ReactiveQuerySpec<Row>,
|
|
197
197
|
): boolean {
|
|
198
198
|
for (const change of batch.tables) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
199
|
+
// A composed spec carries one dependency per query branch. Every
|
|
200
|
+
// dependency on the changed table is consulted: a scoped dependency
|
|
201
|
+
// that does not cover the change must not mask a sibling dependency
|
|
202
|
+
// on the same table that does.
|
|
203
|
+
for (const dependency of spec.dependencies) {
|
|
204
|
+
if (dependency.table !== change.table) continue;
|
|
205
|
+
if (
|
|
206
|
+
dependency.scopeKeys === undefined ||
|
|
207
|
+
change.scopeKeys === undefined
|
|
208
|
+
) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
for (const key of dependency.scopeKeys) {
|
|
212
|
+
if (change.scopeKeys.has(key)) return true;
|
|
213
|
+
}
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
for (const change of batch.windows) {
|
|
@@ -389,14 +395,28 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
389
395
|
this.#onAvailabilityChange();
|
|
390
396
|
if (this.spec.claimCoverage !== false) {
|
|
391
397
|
const claims: Promise<void>[] = [];
|
|
398
|
+
// A composed spec carries one coverage entry per query branch, and
|
|
399
|
+
// several branches can target the same base. One claim per distinct
|
|
400
|
+
// base covers the union of their units; a later entry for the same
|
|
401
|
+
// base does not replace the earlier one.
|
|
402
|
+
const unitsByBase = new Map<
|
|
403
|
+
string,
|
|
404
|
+
{ base: WindowBase; units: Set<string> }
|
|
405
|
+
>();
|
|
392
406
|
for (const coverage of this.spec.coverage ?? []) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
coverage.
|
|
398
|
-
|
|
399
|
-
|
|
407
|
+
const baseKey = windowBaseKey(coverage.base);
|
|
408
|
+
const existing = unitsByBase.get(baseKey);
|
|
409
|
+
if (existing === undefined) {
|
|
410
|
+
unitsByBase.set(baseKey, {
|
|
411
|
+
base: coverage.base,
|
|
412
|
+
units: new Set(coverage.units),
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
for (const unit of coverage.units) existing.units.add(unit);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const { base, units } of unitsByBase.values()) {
|
|
419
|
+
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
400
420
|
}
|
|
401
421
|
const generation = this.#generation;
|
|
402
422
|
this.#claimPending = claims.length > 0;
|
package/src/schema.ts
CHANGED
|
@@ -121,6 +121,21 @@ export function compileClientSchema(
|
|
|
121
121
|
`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
|
+
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
125
|
+
// string every implementation produces identically and local storage
|
|
126
|
+
// resolves without deferring to a SQLite build.
|
|
127
|
+
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
128
|
+
if (
|
|
129
|
+
primaryKeyColumn !== undefined &&
|
|
130
|
+
primaryKeyColumn.type !== 'string' &&
|
|
131
|
+
primaryKeyColumn.type !== 'integer' &&
|
|
132
|
+
primaryKeyColumn.type !== 'boolean' &&
|
|
133
|
+
primaryKeyColumn.type !== 'json'
|
|
134
|
+
) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
124
139
|
if (table.scopes.length === 0) {
|
|
125
140
|
throw new Error(
|
|
126
141
|
`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
|