@syncular/server 0.34.0 → 0.36.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.
@@ -90,7 +90,7 @@ import {
90
90
  upsertValues,
91
91
  } from './relational-rows';
92
92
  import type { CompiledSchema, CompiledTable } from './schema';
93
- import { matchesEffective } from './scopes';
93
+ import { matchesEffective, sha256Hex } from './scopes';
94
94
  import type {
95
95
  AuthoritativeQueryRequest,
96
96
  AuthoritativeQueryResult,
@@ -121,6 +121,8 @@ import type {
121
121
  RowScanQuery,
122
122
  ScopeActivityQuery,
123
123
  ScopeCommitActivity,
124
+ ScopeValueHistoryQuery,
125
+ ScopeValueQuery,
124
126
  ServerStorage,
125
127
  ServeGate,
126
128
  StorageTransaction,
@@ -1277,10 +1279,11 @@ export class PostgresServerStorage implements ServerStorage {
1277
1279
  #schemaVersion: number | undefined;
1278
1280
  /**
1279
1281
  * Page reads waiting for the end of the current microtask turn, keyed by
1280
- * statement and fixed params. A pull starts the window read (or first
1282
+ * statement and partition. A pull starts the window read (or first
1281
1283
  * snapshot page) of every subscription before awaiting any of them, so the
1282
- * reads of one table leave as ONE set-based statement instead of one round
1283
- * trip per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
1284
+ * window reads of all tables leave as ONE set-based statement (snapshot
1285
+ * pages as one per table, whose SQL names the table) instead of one round
1286
+ * trip per subscription or table (SYNCULAR-PULL-ROUNDTRIPS-001).
1284
1287
  */
1285
1288
  readonly #pageBatches = new Map<string, PendingPage[]>();
1286
1289
 
@@ -1289,18 +1292,17 @@ export class PostgresServerStorage implements ServerStorage {
1289
1292
  }
1290
1293
 
1291
1294
  /**
1292
- * Queue one page read into the batch for (`sql`, `partition`, `table`).
1293
- * The batch runs as a single statement with the queued specs bound as a
1294
- * JSON array (`$2`); each spec gets its position as `i`, and the result
1295
- * rows are split back by their `sub` column.
1295
+ * Queue one page read into the batch for (`sql`, `partition`). The batch
1296
+ * runs as a single statement with the queued specs bound as a JSON array
1297
+ * (`$2`); each spec gets its position as `i`, and the result rows are
1298
+ * split back by their `sub` column.
1296
1299
  */
1297
1300
  #batchedPage<Row>(
1298
1301
  sql: string,
1299
1302
  partition: string,
1300
- table: string,
1301
1303
  spec: Record<string, unknown>,
1302
1304
  ): Promise<Row[]> {
1303
- const key = `${sql}\u0000${partition}\u0000${table}`;
1305
+ const key = `${sql}\u0000${partition}`;
1304
1306
  let batch = this.#pageBatches.get(key);
1305
1307
  if (batch === undefined) {
1306
1308
  const queued: PendingPage[] = [];
@@ -1311,7 +1313,7 @@ export class PostgresServerStorage implements ServerStorage {
1311
1313
  const specs = JSON.stringify(
1312
1314
  queued.map((entry, i) => ({ ...entry.spec, i })),
1313
1315
  );
1314
- this.#exec.query<PgRow>(sql, [partition, specs, table]).then(
1316
+ this.#exec.query<PgRow>(sql, [partition, specs]).then(
1315
1317
  ({ rows }) => {
1316
1318
  const grouped = queued.map((): PgRow[] => []);
1317
1319
  for (const row of rows) grouped[asNumber(row.sub)]?.push(row);
@@ -1333,24 +1335,27 @@ export class PostgresServerStorage implements ServerStorage {
1333
1335
  });
1334
1336
  }
1335
1337
 
1336
- /** Apply the schema DDL (idempotent). Call once before use. */
1338
+ /**
1339
+ * Apply the schema DDL (idempotent). Call once before use. The last
1340
+ * statement records a fingerprint of the applied DDL as the comment of
1341
+ * `sync_schema_meta`; a database that carries the current fingerprint
1342
+ * costs one catalog read instead of the DDL round trips, so a new storage
1343
+ * instance (a Worker isolate, a Durable Object wake) starts fast.
1344
+ */
1337
1345
  async migrate(): Promise<void> {
1338
1346
  // Split on the statement boundary so drivers that reject multi-statement
1339
1347
  // query strings (node-postgres extended protocol) still apply each DDL.
1340
- const statements = POSTGRES_DDL.split(';')
1341
- .map((s) => s.trim())
1342
- .filter((s) => s.length > 0);
1343
- for (const statement of statements) {
1344
- await this.#exec.query(statement);
1345
- }
1346
- await this.#exec.query(
1348
+ // The fence function is sent whole because its dollar-quoted body
1349
+ // contains `;`.
1350
+ const statements = [
1351
+ ...POSTGRES_DDL.split(';')
1352
+ .map((s) => s.trim())
1353
+ .filter((s) => s.length > 0),
1347
1354
  'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
1348
- );
1349
- // Database-side fence: an old binary's INSERT never reaches JS. An absent
1350
- // `sync_writer_fence` row makes the EXISTS false, so a partition with no
1351
- // barrier behaves exactly as before. Sent as separate statements because
1352
- // the dollar-quoted body contains `;`.
1353
- await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
1355
+ // Database-side fence: an old binary's INSERT never reaches JS. An
1356
+ // absent `sync_writer_fence` row makes the EXISTS false, so a partition
1357
+ // with no barrier behaves exactly as before.
1358
+ `CREATE OR REPLACE FUNCTION syncular_writer_fence()
1354
1359
  RETURNS trigger AS $$
1355
1360
  BEGIN
1356
1361
  IF EXISTS (
@@ -1363,13 +1368,45 @@ BEGIN
1363
1368
  END IF;
1364
1369
  RETURN NEW;
1365
1370
  END;
1366
- $$ LANGUAGE plpgsql`);
1367
- await this.#exec.query(
1371
+ $$ LANGUAGE plpgsql`,
1368
1372
  'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
1369
- );
1370
- await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
1373
+ `CREATE TRIGGER sync_commits_writer_fence
1371
1374
  BEFORE INSERT ON sync_commits
1372
- FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1375
+ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
1376
+ SCHEMA_META_DDL_POSTGRES,
1377
+ ];
1378
+ const fingerprint = `syncular.storage:${await sha256Hex(
1379
+ new TextEncoder().encode(statements.join('\u0000')),
1380
+ )}`;
1381
+ // `obj_description` and `to_regclass` of a missing table are NULL, so
1382
+ // the probe never fails. A core table dropped out of band reapplies the
1383
+ // DDL even under the current fingerprint.
1384
+ const { rows } = await this.#exec.query<{
1385
+ storage: string | null;
1386
+ missing: number;
1387
+ }>(
1388
+ `SELECT obj_description(to_regclass('sync_schema_meta'), 'pg_class') AS storage,
1389
+ (SELECT count(*)::int
1390
+ FROM jsonb_array_elements_text($1::text::jsonb) AS core(name)
1391
+ WHERE to_regclass(core.name) IS NULL) AS missing`,
1392
+ [
1393
+ JSON.stringify(
1394
+ statements.flatMap(
1395
+ (statement) =>
1396
+ /^CREATE TABLE IF NOT EXISTS (\w+)/.exec(statement)?.[1] ?? [],
1397
+ ),
1398
+ ),
1399
+ ],
1400
+ );
1401
+ if (rows[0]?.storage === fingerprint && rows[0].missing === 0) return;
1402
+ for (const statement of statements) {
1403
+ await this.#exec.query(statement);
1404
+ }
1405
+ // The fingerprint is lowercase hex, safe to inline (COMMENT takes no
1406
+ // bound parameter).
1407
+ await this.#exec.query(
1408
+ `COMMENT ON TABLE sync_schema_meta IS '${fingerprint}'`,
1409
+ );
1373
1410
  }
1374
1411
 
1375
1412
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -1390,21 +1427,63 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1390
1427
  // Memoized fast path: same instance, same schema version.
1391
1428
  if (this.#schemaVersion === schema.version) return;
1392
1429
  await this.migrate();
1430
+ // One statement reads the marker, the incomplete checkpoints of this
1431
+ // version and the physical columns of every app table. The aggregates
1432
+ // leave as text so every driver hands back the same JSON strings.
1433
+ // Columns resolve through `to_regclass`, which follows the session's
1434
+ // `search_path` exactly as the unqualified row queries do:
1435
+ // `current_schema()` names only the first existing schema, so it would
1436
+ // reject a table that resolves later in the path.
1437
+ const { rows: probe } = await this.#exec.query<{
1438
+ schema_version: unknown;
1439
+ layouts: unknown;
1440
+ incomplete: string;
1441
+ columns: string;
1442
+ }>(
1443
+ `SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
1444
+ (SELECT coalesce(json_agg(json_build_object(
1445
+ 'partition', checkpoint.partition,
1446
+ 'name', checkpoint.name)), '[]'::json)::text
1447
+ FROM sync_backfill_checkpoints checkpoint
1448
+ WHERE checkpoint.schema_version=$1
1449
+ AND checkpoint.state<>'activated') AS incomplete,
1450
+ (SELECT coalesce(json_agg(json_build_object(
1451
+ 'tbl', tables.name,
1452
+ 'name', attribute.attname,
1453
+ 'type', format_type(attribute.atttypid, attribute.atttypmod),
1454
+ 'not_null', attribute.attnotnull,
1455
+ 'pk', (SELECT key.position::int
1456
+ FROM pg_index pk,
1457
+ unnest(pk.indkey::int2[]) WITH ORDINALITY
1458
+ AS key(attnum, position)
1459
+ WHERE pk.indrelid = attribute.attrelid
1460
+ AND pk.indisprimary
1461
+ AND key.attnum = attribute.attnum))
1462
+ ORDER BY tables.name, attribute.attnum), '[]'::json)::text
1463
+ FROM jsonb_array_elements_text($2::text::jsonb) AS tables(name)
1464
+ JOIN pg_attribute attribute
1465
+ ON attribute.attrelid = to_regclass(tables.name)
1466
+ WHERE attribute.attnum > 0
1467
+ AND NOT attribute.attisdropped) AS columns
1468
+ FROM (SELECT 1) AS one
1469
+ LEFT JOIN sync_schema_meta meta ON meta.id=1`,
1470
+ [schema.version, JSON.stringify([...schema.tables.keys()])],
1471
+ );
1472
+ const marker = probe[0];
1473
+ if (marker === undefined) {
1474
+ throw new Error('schema probe returned no row');
1475
+ }
1393
1476
  // The barrier reads stored state: a checkpoint at this schema version that
1394
1477
  // is not activated and that this caller did not declare means this process
1395
1478
  // must not serve. An omitted or empty declaration set declares nothing and
1396
1479
  // is refused here, never treated as a bypass.
1397
1480
  const declarations = checkpoints ?? [];
1398
- const incomplete = await this.#exec.query<{
1481
+ const incomplete = JSON.parse(marker.incomplete) as {
1399
1482
  partition: string;
1400
1483
  name: string;
1401
- }>(
1402
- `SELECT partition, name FROM sync_backfill_checkpoints
1403
- WHERE schema_version=$1 AND state<>'activated'`,
1404
- [schema.version],
1405
- );
1484
+ }[];
1406
1485
  if (
1407
- incomplete.rows.some(
1486
+ incomplete.some(
1408
1487
  (row) =>
1409
1488
  !declarations.some(
1410
1489
  (declaration) =>
@@ -1415,15 +1494,10 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1415
1494
  ) {
1416
1495
  throw new StorageQueryError('sync.storage.checkpoint_incomplete');
1417
1496
  }
1418
- await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
1419
- const marker = await this.#exec.query<{
1420
- schema_version: unknown;
1421
- layouts: unknown;
1422
- }>('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
1423
1497
  const stored =
1424
- marker.rows[0] === undefined
1498
+ marker.schema_version === null || marker.schema_version === undefined
1425
1499
  ? undefined
1426
- : asNumber(marker.rows[0].schema_version);
1500
+ : asNumber(marker.schema_version);
1427
1501
  if (stored !== undefined && stored > schema.version) {
1428
1502
  throw new Error(
1429
1503
  `stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
@@ -1432,43 +1506,26 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1432
1506
  if (stored !== undefined && stored === schema.version) {
1433
1507
  assertStoredLayouts(
1434
1508
  schema,
1435
- typeof marker.rows[0]?.layouts === 'string'
1436
- ? marker.rows[0].layouts
1437
- : undefined,
1509
+ typeof marker.layouts === 'string' ? marker.layouts : undefined,
1438
1510
  );
1439
- // Resolve through `to_regclass`, which follows the session's
1440
- // `search_path` exactly as the unqualified row queries do:
1441
- // `current_schema()` names only the first existing schema, so it would
1442
- // reject a table that resolves later in the path.
1511
+ const columns = JSON.parse(marker.columns) as {
1512
+ tbl: string;
1513
+ name: string;
1514
+ type: string;
1515
+ not_null: boolean;
1516
+ pk: number | null;
1517
+ }[];
1443
1518
  for (const table of schema.tables.values()) {
1444
- const { rows } = await this.#exec.query<{
1445
- name: string;
1446
- type: string;
1447
- not_null: boolean;
1448
- pk: number | null;
1449
- }>(
1450
- `SELECT attribute.attname AS name,
1451
- format_type(attribute.atttypid, attribute.atttypmod) AS type,
1452
- attribute.attnotnull AS not_null,
1453
- (SELECT key.position::int
1454
- FROM pg_index pk,
1455
- unnest(pk.indkey::int2[]) WITH ORDINALITY
1456
- AS key(attnum, position)
1457
- WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
1458
- AND key.attnum = attribute.attnum) AS pk
1459
- FROM pg_attribute attribute
1460
- WHERE attribute.attrelid = to_regclass($1)
1461
- AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
1462
- [table.name],
1463
- );
1464
1519
  assertPhysicalColumns(
1465
1520
  table,
1466
- rows.map((row) => ({
1467
- name: row.name,
1468
- type: row.type,
1469
- notNull: row.not_null,
1470
- primaryKeyPosition: row.pk ?? 0,
1471
- })),
1521
+ columns
1522
+ .filter((column) => column.tbl === table.name)
1523
+ .map((column) => ({
1524
+ name: column.name,
1525
+ type: column.type,
1526
+ notNull: column.not_null,
1527
+ primaryKeyPosition: column.pk ?? 0,
1528
+ })),
1472
1529
  'postgres',
1473
1530
  );
1474
1531
  }
@@ -1481,9 +1538,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1481
1538
  // transaction (Postgres DDL is transactional — a failed bump leaves
1482
1539
  // no half-state).
1483
1540
  const layouts = parseLayouts(
1484
- typeof marker.rows[0]?.layouts === 'string'
1485
- ? marker.rows[0].layouts
1486
- : undefined,
1541
+ typeof marker.layouts === 'string' ? marker.layouts : undefined,
1487
1542
  );
1488
1543
  const retiredTables = retiredTableNames(schema, layouts);
1489
1544
  await this.#exec.transaction(async (client) => {
@@ -2260,12 +2315,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2260
2315
  ? []
2261
2316
  : (query.scopeFilter[firstVariable] ?? []);
2262
2317
  if (firstVariable === undefined || values.length === 0) return 0;
2263
- // Concurrent reads of one table in a pull share one statement.
2318
+ // Concurrent reads of a pull share one statement across tables.
2264
2319
  const [row] = await this.#batchedPage<{ seq: unknown }>(
2265
2320
  LATEST_SCOPED_CHANGE_BATCH_SQL,
2266
2321
  partition,
2267
- query.table,
2268
- { var: firstVariable, vals: values },
2322
+ { tbl: query.table, var: firstVariable, vals: values },
2269
2323
  );
2270
2324
  return row === undefined ? 0 : asNumber(row.seq);
2271
2325
  }
@@ -2283,7 +2337,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2283
2337
  // var, value, commit_seq) PK makes the candidate subquery an index range
2284
2338
  // scan returning commit_seq already ascending (see postgres-explain
2285
2339
  // .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
2286
- // of concurrent window reads on the same table share one statement (see
2340
+ // of concurrent window reads share one statement across tables (see
2287
2341
  // `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
2288
2342
  // the stored scope map happens below.
2289
2343
  const commits: StoredCommit[] = [];
@@ -2294,8 +2348,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2294
2348
  const records = await this.#batchedPage<CommitWindowRecord>(
2295
2349
  COMMIT_WINDOW_BATCH_SQL,
2296
2350
  partition,
2297
- query.table,
2298
2351
  {
2352
+ tbl: query.table,
2299
2353
  var: firstVariable,
2300
2354
  vals: firstValues,
2301
2355
  after: afterSeq,
@@ -2368,17 +2422,13 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2368
2422
  let afterRowId = query.afterRowId ?? '';
2369
2423
  const batchSize = Math.max(64, query.limit);
2370
2424
  while (rows.length < query.limit) {
2371
- const records = await this.#batchedPage<RowRecord>(
2372
- sql,
2373
- partition,
2374
- query.table,
2375
- {
2376
- var: firstVariable,
2377
- vals: firstValues,
2378
- after: afterRowId,
2379
- lim: batchSize,
2380
- },
2381
- );
2425
+ const records = await this.#batchedPage<RowRecord>(sql, partition, {
2426
+ tbl: query.table,
2427
+ var: firstVariable,
2428
+ vals: firstValues,
2429
+ after: afterRowId,
2430
+ lim: batchSize,
2431
+ });
2382
2432
  if (records.length === 0) break;
2383
2433
  for (const record of records) {
2384
2434
  afterRowId = record.row_id;
@@ -2567,6 +2617,63 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2567
2617
  return rows.map((r) => r.blob_id);
2568
2618
  }
2569
2619
 
2620
+ async listScopeValueRows(
2621
+ partition: string,
2622
+ query: ScopeValueQuery,
2623
+ ): Promise<string[]> {
2624
+ const { rows } = await this.#exec.query<{ row_id: string }>(
2625
+ `SELECT row_id FROM sync_row_scopes
2626
+ WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
2627
+ ORDER BY row_id LIMIT $5`,
2628
+ [partition, query.table, query.variable, query.value, query.limit],
2629
+ );
2630
+ return rows.map((row) => row.row_id);
2631
+ }
2632
+
2633
+ async eraseScopeValueHistory(
2634
+ partition: string,
2635
+ query: ScopeValueHistoryQuery,
2636
+ ): Promise<number> {
2637
+ const params = [
2638
+ partition,
2639
+ query.table,
2640
+ query.variable,
2641
+ query.value,
2642
+ query.throughSeq,
2643
+ query.limit,
2644
+ ];
2645
+ return this.#exec.transaction(async (client) => {
2646
+ await lockPartitionOn(client, partition);
2647
+ // Upsert change records of the binding, then the change scope entries
2648
+ // whose commit no longer holds a change of the binding.
2649
+ const changes = await client.query(
2650
+ `DELETE FROM sync_changes c USING (
2651
+ SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
2652
+ JOIN sync_changes c2 ON c2.partition=s.partition
2653
+ AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
2654
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
2655
+ AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
2656
+ LIMIT $6) d
2657
+ WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`,
2658
+ params,
2659
+ );
2660
+ const scopes = await client.query(
2661
+ `DELETE FROM sync_change_scopes cs USING (
2662
+ SELECT s.commit_seq FROM sync_change_scopes s
2663
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
2664
+ AND s.commit_seq<=$5 AND NOT EXISTS (
2665
+ SELECT 1 FROM sync_changes c
2666
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
2667
+ AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
2668
+ LIMIT $6) d
2669
+ WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
2670
+ AND cs.commit_seq=d.commit_seq`,
2671
+ params,
2672
+ );
2673
+ return changes.rowCount + scopes.rowCount;
2674
+ });
2675
+ }
2676
+
2570
2677
  // -- admin/console read surface --------------------------------------------
2571
2678
 
2572
2679
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
package/src/push.ts CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  * columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
76
76
  * row-codec decode (§5.9.1), so `parseBlobRef` here is total.
77
77
  */
78
- function blobIdsInRow(
78
+ export function blobIdsInRow(
79
79
  table: CompiledTable,
80
80
  values: readonly RowValue[],
81
81
  ): string[] {
@@ -647,20 +647,20 @@ export function commitWindowPageSql(
647
647
  /**
648
648
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
649
649
  * page of every subscription scanning `table` in a pull. `$2` is a JSON array
650
- * of `{i, var, vals, after, lim}` records; each record runs the same
650
+ * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
651
651
  * index-first candidate subquery through a LATERAL join, so every
652
652
  * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
653
653
  * by (i, row_id).
654
654
  *
655
- * Params: [partition, specs (JSON text), tbl]. The specs bind as text before
655
+ * Params: [partition, specs (JSON text)]. The specs bind as text before
656
656
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
657
657
  * parameter (Bun.sql) cannot turn the array into a JSON string.
658
658
  */
659
659
  export function scanRowBatchSql(table: CompiledTable): string {
660
660
  return `SELECT s.i AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
661
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
661
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
662
662
  CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
663
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
663
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
664
664
  AND row_id>s.after
665
665
  ORDER BY row_id LIMIT s.lim) c
666
666
  LEFT JOIN ${quoteIdent(table.name)} r
@@ -670,25 +670,25 @@ export function scanRowBatchSql(table: CompiledTable): string {
670
670
 
671
671
  /**
672
672
  * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
673
- * the window page of every subscription reading `tbl` in a pull. `$2` is a
674
- * JSON array of `{i, var, vals, after, through, lim}` records, each running
675
- * the same index-first candidate subquery through a LATERAL join. Rows
676
- * arrive ordered by (i, commit_seq, idx).
673
+ * the window page of every subscription in a pull, across all tables. `$2`
674
+ * is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
675
+ * running the same index-first candidate subquery through a LATERAL join.
676
+ * Rows arrive ordered by (i, commit_seq, idx).
677
677
  *
678
- * Params: [partition, specs (JSON text), tbl], the specs bound as text like
678
+ * Params: [partition, specs (JSON text)], the specs bound as text like
679
679
  * `scanRowBatchSql`.
680
680
  */
681
681
  export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,
682
682
  ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload
683
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
683
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)
684
684
  CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
685
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
685
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
686
686
  AND commit_seq>s.after AND commit_seq<=s.through
687
687
  ORDER BY commit_seq LIMIT s.lim) c
688
688
  LEFT JOIN sync_commits m
689
689
  ON m.partition=$1 AND m.commit_seq=c.commit_seq
690
690
  LEFT JOIN sync_changes ch
691
- ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
691
+ ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
692
692
  ORDER BY s.i, c.commit_seq, ch.idx`;
693
693
 
694
694
  /**
@@ -720,15 +720,15 @@ export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commi
720
720
 
721
721
  /**
722
722
  * PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
723
- * subscriptions of one table in a pull. `$2` is a JSON array of
724
- * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
723
+ * subscriptions of a pull, across all tables. `$2` is a JSON array of
724
+ * `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
725
725
  */
726
726
  export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
727
727
  (SELECT max(latest.seq) FROM (
728
728
  SELECT (SELECT max(commit_seq) FROM sync_change_scopes
729
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
729
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq
730
730
  FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
731
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
731
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)
732
732
  ORDER BY s.i`;
733
733
 
734
734
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
@@ -33,7 +33,17 @@ export class SqliteBlobStore implements BlobStore {
33
33
  mediaType?: string,
34
34
  ): Promise<BlobRecord> {
35
35
  const existing = await this.get(partition, blobId);
36
- if (existing !== undefined) return existing.record;
36
+ if (existing !== undefined) {
37
+ if (existing.record.mediaType !== undefined || mediaType === undefined)
38
+ return existing.record;
39
+ // A re-upload names the media type an earlier upload left out.
40
+ this.db
41
+ .query(
42
+ 'UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL',
43
+ )
44
+ .run(mediaType, partition, blobId);
45
+ return { ...existing.record, mediaType };
46
+ }
37
47
  const record: BlobRecord = {
38
48
  blobId,
39
49
  partition,
@@ -51,6 +61,12 @@ export class SqliteBlobStore implements BlobStore {
51
61
  return record;
52
62
  }
53
63
 
64
+ async delete(partition: string, blobId: string): Promise<void> {
65
+ this.db
66
+ .query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
67
+ .run(partition, blobId);
68
+ }
69
+
54
70
  async has(partition: string, blobId: string): Promise<boolean> {
55
71
  const row = this.db
56
72
  .query<{ n: number }, [string, string]>(
@@ -18,6 +18,7 @@
18
18
  import type { PushOperationResult, ScopeMap } from '@syncular/core';
19
19
  import { matchesEffective } from './scopes';
20
20
  import type {
21
+ ScopeValueHistoryQuery,
21
22
  StoredChange,
22
23
  StoredCommit,
23
24
  StoredPushResult,
@@ -151,6 +152,48 @@ export function sqliteDdlStatements(): string[] {
151
152
  .filter((statement) => statement.length > 0);
152
153
  }
153
154
 
155
+ /** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
156
+ export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL =
157
+ 'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
158
+
159
+ /**
160
+ * §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
161
+ * records of the binding first, then the change scope entries whose commit
162
+ * no longer holds a change of the binding. Each returns one row per removed
163
+ * record (D1 batch results carry no reliable change count).
164
+ */
165
+ export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
166
+ `DELETE FROM sync_changes WHERE rowid IN (
167
+ SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
168
+ ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
169
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
170
+ AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
171
+ LIMIT ?7) RETURNING 1 AS removed`,
172
+ `DELETE FROM sync_change_scopes WHERE rowid IN (
173
+ SELECT s.rowid FROM sync_change_scopes s
174
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
175
+ AND s.commit_seq<=?5 AND NOT EXISTS (
176
+ SELECT 1 FROM sync_changes c
177
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
178
+ AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
179
+ LIMIT ?7) RETURNING 1 AS removed`,
180
+ ] as const;
181
+
182
+ export function eraseScopeValueHistoryParams(
183
+ partition: string,
184
+ query: ScopeValueHistoryQuery,
185
+ ): [string, string, string, string, number, string, number] {
186
+ return [
187
+ partition,
188
+ query.table,
189
+ query.variable,
190
+ query.value,
191
+ query.throughSeq,
192
+ `$.${JSON.stringify(query.variable)}`,
193
+ query.limit,
194
+ ];
195
+ }
196
+
154
197
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
155
198
  export function placeholders(count: number): string {
156
199
  return new Array(count).fill('?').join(',');
@@ -56,6 +56,9 @@ import { matchesEffective } from './scopes';
56
56
  import {
57
57
  collectCommitWindowPage,
58
58
  deserializePushResult,
59
+ ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL,
60
+ eraseScopeValueHistoryParams,
61
+ LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
59
62
  placeholders,
60
63
  SQLITE_DDL,
61
64
  type SqliteChangeRecord,
@@ -100,6 +103,8 @@ import type {
100
103
  RowScanQuery,
101
104
  ScopeActivityQuery,
102
105
  ScopeCommitActivity,
106
+ ScopeValueHistoryQuery,
107
+ ScopeValueQuery,
103
108
  ServerStorage,
104
109
  ServeGate,
105
110
  StorageTransaction,
@@ -1925,6 +1930,40 @@ END`);
1925
1930
  return rows.map((r) => r.blob_id);
1926
1931
  }
1927
1932
 
1933
+ async listScopeValueRows(
1934
+ partition: string,
1935
+ query: ScopeValueQuery,
1936
+ ): Promise<string[]> {
1937
+ return this.db
1938
+ .query<{ row_id: string }, [string, string, string, string, number]>(
1939
+ LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
1940
+ )
1941
+ .all(partition, query.table, query.variable, query.value, query.limit)
1942
+ .map((row) => row.row_id);
1943
+ }
1944
+
1945
+ async eraseScopeValueHistory(
1946
+ partition: string,
1947
+ query: ScopeValueHistoryQuery,
1948
+ ): Promise<number> {
1949
+ const params = eraseScopeValueHistoryParams(partition, query);
1950
+ return this.#serializeWrite(() => {
1951
+ this.db.exec('BEGIN IMMEDIATE');
1952
+ try {
1953
+ let removed = 0;
1954
+ for (const sql of ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL) {
1955
+ removed += this.db.query(sql).all(...params).length;
1956
+ }
1957
+ this.db.exec('COMMIT');
1958
+ return removed;
1959
+ } catch (error) {
1960
+ rollbackAfterError(() => {
1961
+ this.db.exec('ROLLBACK');
1962
+ }, error);
1963
+ }
1964
+ });
1965
+ }
1966
+
1928
1967
  // -- admin/console read surface --------------------------------------------
1929
1968
 
1930
1969
  async listClientRecords(partition: string): Promise<ClientRecord[]> {