@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 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
- if (scopeColumns.has(column.name)) {
1421
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
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`);
@@ -82,15 +82,21 @@ function scheduleMicrotask(task) {
82
82
  }
83
83
  function batchMatches(batch, spec) {
84
84
  for (const change of batch.tables) {
85
- const dependency = spec.dependencies.find((candidate) => candidate.table === change.table);
86
- if (dependency === undefined)
87
- continue;
88
- if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
89
- return true;
90
- }
91
- for (const key of dependency.scopeKeys) {
92
- if (change.scopeKeys.has(key))
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
- claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
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.21.1",
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.21.1"
92
+ "@syncular/core": "0.22.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.21.1",
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
- if (scopeColumns.has(column.name)) {
2160
- throw new ClientSyncError(
2161
- 'sync.invalid_request',
2162
- `table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`,
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(
@@ -196,15 +196,21 @@ function batchMatches<Row>(
196
196
  spec: ReactiveQuerySpec<Row>,
197
197
  ): boolean {
198
198
  for (const change of batch.tables) {
199
- const dependency = spec.dependencies.find(
200
- (candidate) => candidate.table === change.table,
201
- );
202
- if (dependency === undefined) continue;
203
- if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
204
- return true;
205
- }
206
- for (const key of dependency.scopeKeys) {
207
- if (change.scopeKeys.has(key)) return true;
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
- claims.push(
394
- this.store.setWindowClaim(
395
- this.#owner,
396
- coverage.base,
397
- coverage.units,
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)`,