@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.
@@ -6,7 +6,7 @@ import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQ
6
6
  import { syncError } from './errors.js';
7
7
  import { asBytes, asNumber, } from './pg-executor.js';
8
8
  import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
- import { matchesEffective } from './scopes.js';
9
+ import { matchesEffective, sha256Hex } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
12
12
  /**
@@ -794,23 +794,24 @@ export class PostgresServerStorage {
794
794
  #schemaVersion;
795
795
  /**
796
796
  * Page reads waiting for the end of the current microtask turn, keyed by
797
- * statement and fixed params. A pull starts the window read (or first
797
+ * statement and partition. A pull starts the window read (or first
798
798
  * snapshot page) of every subscription before awaiting any of them, so the
799
- * reads of one table leave as ONE set-based statement instead of one round
800
- * trip per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
799
+ * window reads of all tables leave as ONE set-based statement (snapshot
800
+ * pages as one per table, whose SQL names the table) instead of one round
801
+ * trip per subscription or table (SYNCULAR-PULL-ROUNDTRIPS-001).
801
802
  */
802
803
  #pageBatches = new Map();
803
804
  constructor(exec) {
804
805
  this.#exec = exec;
805
806
  }
806
807
  /**
807
- * Queue one page read into the batch for (`sql`, `partition`, `table`).
808
- * The batch runs as a single statement with the queued specs bound as a
809
- * JSON array (`$2`); each spec gets its position as `i`, and the result
810
- * rows are split back by their `sub` column.
808
+ * Queue one page read into the batch for (`sql`, `partition`). The batch
809
+ * runs as a single statement with the queued specs bound as a JSON array
810
+ * (`$2`); each spec gets its position as `i`, and the result rows are
811
+ * split back by their `sub` column.
811
812
  */
812
- #batchedPage(sql, partition, table, spec) {
813
- const key = `${sql}\u0000${partition}\u0000${table}`;
813
+ #batchedPage(sql, partition, spec) {
814
+ const key = `${sql}\u0000${partition}`;
814
815
  let batch = this.#pageBatches.get(key);
815
816
  if (batch === undefined) {
816
817
  const queued = [];
@@ -819,7 +820,7 @@ export class PostgresServerStorage {
819
820
  queueMicrotask(() => {
820
821
  this.#pageBatches.delete(key);
821
822
  const specs = JSON.stringify(queued.map((entry, i) => ({ ...entry.spec, i })));
822
- this.#exec.query(sql, [partition, specs, table]).then(({ rows }) => {
823
+ this.#exec.query(sql, [partition, specs]).then(({ rows }) => {
823
824
  const grouped = queued.map(() => []);
824
825
  for (const row of rows)
825
826
  grouped[asNumber(row.sub)]?.push(row);
@@ -839,22 +840,27 @@ export class PostgresServerStorage {
839
840
  });
840
841
  });
841
842
  }
842
- /** Apply the schema DDL (idempotent). Call once before use. */
843
+ /**
844
+ * Apply the schema DDL (idempotent). Call once before use. The last
845
+ * statement records a fingerprint of the applied DDL as the comment of
846
+ * `sync_schema_meta`; a database that carries the current fingerprint
847
+ * costs one catalog read instead of the DDL round trips, so a new storage
848
+ * instance (a Worker isolate, a Durable Object wake) starts fast.
849
+ */
843
850
  async migrate() {
844
851
  // Split on the statement boundary so drivers that reject multi-statement
845
852
  // query strings (node-postgres extended protocol) still apply each DDL.
846
- const statements = POSTGRES_DDL.split(';')
847
- .map((s) => s.trim())
848
- .filter((s) => s.length > 0);
849
- for (const statement of statements) {
850
- await this.#exec.query(statement);
851
- }
852
- await this.#exec.query('ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT');
853
- // Database-side fence: an old binary's INSERT never reaches JS. An absent
854
- // `sync_writer_fence` row makes the EXISTS false, so a partition with no
855
- // barrier behaves exactly as before. Sent as separate statements because
856
- // the dollar-quoted body contains `;`.
857
- await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
853
+ // The fence function is sent whole because its dollar-quoted body
854
+ // contains `;`.
855
+ const statements = [
856
+ ...POSTGRES_DDL.split(';')
857
+ .map((s) => s.trim())
858
+ .filter((s) => s.length > 0),
859
+ 'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
860
+ // Database-side fence: an old binary's INSERT never reaches JS. An
861
+ // absent `sync_writer_fence` row makes the EXISTS false, so a partition
862
+ // with no barrier behaves exactly as before.
863
+ `CREATE OR REPLACE FUNCTION syncular_writer_fence()
858
864
  RETURNS trigger AS $$
859
865
  BEGIN
860
866
  IF EXISTS (
@@ -867,11 +873,31 @@ BEGIN
867
873
  END IF;
868
874
  RETURN NEW;
869
875
  END;
870
- $$ LANGUAGE plpgsql`);
871
- await this.#exec.query('DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits');
872
- await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
876
+ $$ LANGUAGE plpgsql`,
877
+ 'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
878
+ `CREATE TRIGGER sync_commits_writer_fence
873
879
  BEFORE INSERT ON sync_commits
874
- FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
880
+ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
881
+ SCHEMA_META_DDL_POSTGRES,
882
+ ];
883
+ const fingerprint = `syncular.storage:${await sha256Hex(new TextEncoder().encode(statements.join('\u0000')))}`;
884
+ // `obj_description` and `to_regclass` of a missing table are NULL, so
885
+ // the probe never fails. A core table dropped out of band reapplies the
886
+ // DDL even under the current fingerprint.
887
+ const { rows } = await this.#exec.query(`SELECT obj_description(to_regclass('sync_schema_meta'), 'pg_class') AS storage,
888
+ (SELECT count(*)::int
889
+ FROM jsonb_array_elements_text($1::text::jsonb) AS core(name)
890
+ WHERE to_regclass(core.name) IS NULL) AS missing`, [
891
+ JSON.stringify(statements.flatMap((statement) => /^CREATE TABLE IF NOT EXISTS (\w+)/.exec(statement)?.[1] ?? [])),
892
+ ]);
893
+ if (rows[0]?.storage === fingerprint && rows[0].missing === 0)
894
+ return;
895
+ for (const statement of statements) {
896
+ await this.#exec.query(statement);
897
+ }
898
+ // The fingerprint is lowercase hex, safe to inline (COMMENT takes no
899
+ // bound parameter).
900
+ await this.#exec.query(`COMMENT ON TABLE sync_schema_meta IS '${fingerprint}'`);
875
901
  }
876
902
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
877
903
  table(name) {
@@ -886,51 +912,71 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
886
912
  if (this.#schemaVersion === schema.version)
887
913
  return;
888
914
  await this.migrate();
915
+ // One statement reads the marker, the incomplete checkpoints of this
916
+ // version and the physical columns of every app table. The aggregates
917
+ // leave as text so every driver hands back the same JSON strings.
918
+ // Columns resolve through `to_regclass`, which follows the session's
919
+ // `search_path` exactly as the unqualified row queries do:
920
+ // `current_schema()` names only the first existing schema, so it would
921
+ // reject a table that resolves later in the path.
922
+ const { rows: probe } = await this.#exec.query(`SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
923
+ (SELECT coalesce(json_agg(json_build_object(
924
+ 'partition', checkpoint.partition,
925
+ 'name', checkpoint.name)), '[]'::json)::text
926
+ FROM sync_backfill_checkpoints checkpoint
927
+ WHERE checkpoint.schema_version=$1
928
+ AND checkpoint.state<>'activated') AS incomplete,
929
+ (SELECT coalesce(json_agg(json_build_object(
930
+ 'tbl', tables.name,
931
+ 'name', attribute.attname,
932
+ 'type', format_type(attribute.atttypid, attribute.atttypmod),
933
+ 'not_null', attribute.attnotnull,
934
+ 'pk', (SELECT key.position::int
935
+ FROM pg_index pk,
936
+ unnest(pk.indkey::int2[]) WITH ORDINALITY
937
+ AS key(attnum, position)
938
+ WHERE pk.indrelid = attribute.attrelid
939
+ AND pk.indisprimary
940
+ AND key.attnum = attribute.attnum))
941
+ ORDER BY tables.name, attribute.attnum), '[]'::json)::text
942
+ FROM jsonb_array_elements_text($2::text::jsonb) AS tables(name)
943
+ JOIN pg_attribute attribute
944
+ ON attribute.attrelid = to_regclass(tables.name)
945
+ WHERE attribute.attnum > 0
946
+ AND NOT attribute.attisdropped) AS columns
947
+ FROM (SELECT 1) AS one
948
+ LEFT JOIN sync_schema_meta meta ON meta.id=1`, [schema.version, JSON.stringify([...schema.tables.keys()])]);
949
+ const marker = probe[0];
950
+ if (marker === undefined) {
951
+ throw new Error('schema probe returned no row');
952
+ }
889
953
  // The barrier reads stored state: a checkpoint at this schema version that
890
954
  // is not activated and that this caller did not declare means this process
891
955
  // must not serve. An omitted or empty declaration set declares nothing and
892
956
  // is refused here, never treated as a bypass.
893
957
  const declarations = checkpoints ?? [];
894
- const incomplete = await this.#exec.query(`SELECT partition, name FROM sync_backfill_checkpoints
895
- WHERE schema_version=$1 AND state<>'activated'`, [schema.version]);
896
- if (incomplete.rows.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
958
+ const incomplete = JSON.parse(marker.incomplete);
959
+ if (incomplete.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
897
960
  declaration.name === row.name))) {
898
961
  throw new StorageQueryError('sync.storage.checkpoint_incomplete');
899
962
  }
900
- await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
901
- const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
902
- const stored = marker.rows[0] === undefined
963
+ const stored = marker.schema_version === null || marker.schema_version === undefined
903
964
  ? undefined
904
- : asNumber(marker.rows[0].schema_version);
965
+ : asNumber(marker.schema_version);
905
966
  if (stored !== undefined && stored > schema.version) {
906
967
  throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
907
968
  }
908
969
  if (stored !== undefined && stored === schema.version) {
909
- assertStoredLayouts(schema, typeof marker.rows[0]?.layouts === 'string'
910
- ? marker.rows[0].layouts
911
- : undefined);
912
- // Resolve through `to_regclass`, which follows the session's
913
- // `search_path` exactly as the unqualified row queries do:
914
- // `current_schema()` names only the first existing schema, so it would
915
- // reject a table that resolves later in the path.
970
+ assertStoredLayouts(schema, typeof marker.layouts === 'string' ? marker.layouts : undefined);
971
+ const columns = JSON.parse(marker.columns);
916
972
  for (const table of schema.tables.values()) {
917
- const { rows } = await this.#exec.query(`SELECT attribute.attname AS name,
918
- format_type(attribute.atttypid, attribute.atttypmod) AS type,
919
- attribute.attnotnull AS not_null,
920
- (SELECT key.position::int
921
- FROM pg_index pk,
922
- unnest(pk.indkey::int2[]) WITH ORDINALITY
923
- AS key(attnum, position)
924
- WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
925
- AND key.attnum = attribute.attnum) AS pk
926
- FROM pg_attribute attribute
927
- WHERE attribute.attrelid = to_regclass($1)
928
- AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
929
- assertPhysicalColumns(table, rows.map((row) => ({
930
- name: row.name,
931
- type: row.type,
932
- notNull: row.not_null,
933
- primaryKeyPosition: row.pk ?? 0,
973
+ assertPhysicalColumns(table, columns
974
+ .filter((column) => column.tbl === table.name)
975
+ .map((column) => ({
976
+ name: column.name,
977
+ type: column.type,
978
+ notNull: column.not_null,
979
+ primaryKeyPosition: column.pk ?? 0,
934
980
  })), 'postgres');
935
981
  }
936
982
  }
@@ -941,9 +987,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
941
987
  // backfill for flipped-on materialization) — all inside one
942
988
  // transaction (Postgres DDL is transactional — a failed bump leaves
943
989
  // no half-state).
944
- const layouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
945
- ? marker.rows[0].layouts
946
- : undefined);
990
+ const layouts = parseLayouts(typeof marker.layouts === 'string' ? marker.layouts : undefined);
947
991
  const retiredTables = retiredTableNames(schema, layouts);
948
992
  await this.#exec.transaction(async (client) => {
949
993
  // RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
@@ -1431,8 +1475,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1431
1475
  : (query.scopeFilter[firstVariable] ?? []);
1432
1476
  if (firstVariable === undefined || values.length === 0)
1433
1477
  return 0;
1434
- // Concurrent reads of one table in a pull share one statement.
1435
- const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, query.table, { var: firstVariable, vals: values });
1478
+ // Concurrent reads of a pull share one statement across tables.
1479
+ const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
1436
1480
  return row === undefined ? 0 : asNumber(row.seq);
1437
1481
  }
1438
1482
  async readCommitWindow(partition, query) {
@@ -1447,7 +1491,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1447
1491
  // var, value, commit_seq) PK makes the candidate subquery an index range
1448
1492
  // scan returning commit_seq already ascending (see postgres-explain
1449
1493
  // .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
1450
- // of concurrent window reads on the same table share one statement (see
1494
+ // of concurrent window reads share one statement across tables (see
1451
1495
  // `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
1452
1496
  // the stored scope map happens below.
1453
1497
  const commits = [];
@@ -1455,7 +1499,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1455
1499
  let afterSeq = query.afterSeq;
1456
1500
  const batchSize = Math.max(64, query.limitChanges);
1457
1501
  while (deliveredChanges < query.limitChanges) {
1458
- const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, query.table, {
1502
+ const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, {
1503
+ tbl: query.table,
1459
1504
  var: firstVariable,
1460
1505
  vals: firstValues,
1461
1506
  after: afterSeq,
@@ -1535,7 +1580,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1535
1580
  let afterRowId = query.afterRowId ?? '';
1536
1581
  const batchSize = Math.max(64, query.limit);
1537
1582
  while (rows.length < query.limit) {
1538
- const records = await this.#batchedPage(sql, partition, query.table, {
1583
+ const records = await this.#batchedPage(sql, partition, {
1584
+ tbl: query.table,
1539
1585
  var: firstVariable,
1540
1586
  vals: firstValues,
1541
1587
  after: afterRowId,
@@ -1644,6 +1690,46 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1644
1690
  const { rows } = await this.#exec.query('SELECT DISTINCT blob_id FROM sync_blob_refs WHERE partition=$1', [partition]);
1645
1691
  return rows.map((r) => r.blob_id);
1646
1692
  }
1693
+ async listScopeValueRows(partition, query) {
1694
+ const { rows } = await this.#exec.query(`SELECT row_id FROM sync_row_scopes
1695
+ WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
1696
+ ORDER BY row_id LIMIT $5`, [partition, query.table, query.variable, query.value, query.limit]);
1697
+ return rows.map((row) => row.row_id);
1698
+ }
1699
+ async eraseScopeValueHistory(partition, query) {
1700
+ const params = [
1701
+ partition,
1702
+ query.table,
1703
+ query.variable,
1704
+ query.value,
1705
+ query.throughSeq,
1706
+ query.limit,
1707
+ ];
1708
+ return this.#exec.transaction(async (client) => {
1709
+ await lockPartitionOn(client, partition);
1710
+ // Upsert change records of the binding, then the change scope entries
1711
+ // whose commit no longer holds a change of the binding.
1712
+ const changes = await client.query(`DELETE FROM sync_changes c USING (
1713
+ SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
1714
+ JOIN sync_changes c2 ON c2.partition=s.partition
1715
+ AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
1716
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
1717
+ AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
1718
+ LIMIT $6) d
1719
+ WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`, params);
1720
+ const scopes = await client.query(`DELETE FROM sync_change_scopes cs USING (
1721
+ SELECT s.commit_seq FROM sync_change_scopes s
1722
+ WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
1723
+ AND s.commit_seq<=$5 AND NOT EXISTS (
1724
+ SELECT 1 FROM sync_changes c
1725
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
1726
+ AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
1727
+ LIMIT $6) d
1728
+ WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
1729
+ AND cs.commit_seq=d.commit_seq`, params);
1730
+ return changes.rowCount + scopes.rowCount;
1731
+ });
1732
+ }
1647
1733
  // -- admin/console read surface --------------------------------------------
1648
1734
  async listClientRecords(partition) {
1649
1735
  const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
package/dist/push.d.ts CHANGED
@@ -17,11 +17,17 @@
17
17
  * merge); a validator throw rejects the whole commit atomically with a
18
18
  * host code.
19
19
  */
20
- import { type PushCommitFrame, type PushOperation, type PushResultFrame } from '@syncular/core';
20
+ import { type PushCommitFrame, type PushOperation, type PushResultFrame, type RowValue } from '@syncular/core';
21
21
  import type { SyncRequestContext } from './context.js';
22
- import type { CompiledSchema } from './schema.js';
22
+ import type { CompiledSchema, CompiledTable } from './schema.js';
23
23
  import type { ResolvedScopes } from './scopes.js';
24
24
  import type { StorageTransaction, StoredCommit } from './storage.js';
25
+ /**
26
+ * Extract the blobIds a decoded row references through its `blob_ref`
27
+ * columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
28
+ * row-codec decode (§5.9.1), so `parseBlobRef` here is total.
29
+ */
30
+ export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
25
31
  export interface ProcessedPushCommit {
26
32
  readonly frame: PushResultFrame;
27
33
  /** True when this request observed an already-recorded idempotency outcome. */
package/dist/push.js CHANGED
@@ -35,7 +35,7 @@ import { CommitValidationRejection, toValidateRow, ValidationRejection, } from '
35
35
  * columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
36
36
  * row-codec decode (§5.9.1), so `parseBlobRef` here is total.
37
37
  */
38
- function blobIdsInRow(table, values) {
38
+ export function blobIdsInRow(table, values) {
39
39
  const ids = [];
40
40
  for (const index of table.blobRefColumnIndices) {
41
41
  const value = values[index];
@@ -228,27 +228,27 @@ export declare function commitWindowPageSql(valueCount: number, dialect: Relatio
228
228
  /**
229
229
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
230
230
  * page of every subscription scanning `table` in a pull. `$2` is a JSON array
231
- * of `{i, var, vals, after, lim}` records; each record runs the same
231
+ * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
232
232
  * index-first candidate subquery through a LATERAL join, so every
233
233
  * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
234
234
  * by (i, row_id).
235
235
  *
236
- * Params: [partition, specs (JSON text), tbl]. The specs bind as text before
236
+ * Params: [partition, specs (JSON text)]. The specs bind as text before
237
237
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
238
238
  * parameter (Bun.sql) cannot turn the array into a JSON string.
239
239
  */
240
240
  export declare function scanRowBatchSql(table: CompiledTable): string;
241
241
  /**
242
242
  * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
243
- * the window page of every subscription reading `tbl` in a pull. `$2` is a
244
- * JSON array of `{i, var, vals, after, through, lim}` records, each running
245
- * the same index-first candidate subquery through a LATERAL join. Rows
246
- * arrive ordered by (i, commit_seq, idx).
243
+ * the window page of every subscription in a pull, across all tables. `$2`
244
+ * is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
245
+ * running the same index-first candidate subquery through a LATERAL join.
246
+ * Rows arrive ordered by (i, commit_seq, idx).
247
247
  *
248
- * Params: [partition, specs (JSON text), tbl], the specs bound as text like
248
+ * Params: [partition, specs (JSON text)], the specs bound as text like
249
249
  * `scanRowBatchSql`.
250
250
  */
251
- export declare 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,\n 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\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3\n ORDER BY s.i, c.commit_seq, ch.idx";
251
+ export declare 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,\n 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\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl\n ORDER BY s.i, c.commit_seq, ch.idx";
252
252
  /**
253
253
  * PostgreSQL push read-ahead: every requested row id with its stored row
254
254
  * (NULL columns when absent, `selectRowSql` shape) and its §5 delete
@@ -267,10 +267,10 @@ export declare function prefetchRowsSql(table: CompiledTable): string;
267
267
  export declare const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = "SELECT max((SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq\n FROM json_each(?) AS v";
268
268
  /**
269
269
  * PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
270
- * subscriptions of one table in a pull. `$2` is a JSON array of
271
- * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
270
+ * subscriptions of a pull, across all tables. `$2` is a JSON array of
271
+ * `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
272
272
  */
273
- export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq\n FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)\n ORDER BY s.i";
273
+ export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq\n FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)\n ORDER BY s.i";
274
274
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
275
275
  export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
276
276
  /** DELETE one stored row. Params: [partition, rowId]. */
@@ -551,20 +551,20 @@ export function commitWindowPageSql(valueCount, dialect) {
551
551
  /**
552
552
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
553
553
  * page of every subscription scanning `table` in a pull. `$2` is a JSON array
554
- * of `{i, var, vals, after, lim}` records; each record runs the same
554
+ * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
555
555
  * index-first candidate subquery through a LATERAL join, so every
556
556
  * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
557
557
  * by (i, row_id).
558
558
  *
559
- * Params: [partition, specs (JSON text), tbl]. The specs bind as text before
559
+ * Params: [partition, specs (JSON text)]. The specs bind as text before
560
560
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
561
561
  * parameter (Bun.sql) cannot turn the array into a JSON string.
562
562
  */
563
563
  export function scanRowBatchSql(table) {
564
564
  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
565
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
565
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
566
566
  CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
567
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
567
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
568
568
  AND row_id>s.after
569
569
  ORDER BY row_id LIMIT s.lim) c
570
570
  LEFT JOIN ${quoteIdent(table.name)} r
@@ -573,25 +573,25 @@ export function scanRowBatchSql(table) {
573
573
  }
574
574
  /**
575
575
  * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
576
- * the window page of every subscription reading `tbl` in a pull. `$2` is a
577
- * JSON array of `{i, var, vals, after, through, lim}` records, each running
578
- * the same index-first candidate subquery through a LATERAL join. Rows
579
- * arrive ordered by (i, commit_seq, idx).
576
+ * the window page of every subscription in a pull, across all tables. `$2`
577
+ * is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
578
+ * running the same index-first candidate subquery through a LATERAL join.
579
+ * Rows arrive ordered by (i, commit_seq, idx).
580
580
  *
581
- * Params: [partition, specs (JSON text), tbl], the specs bound as text like
581
+ * Params: [partition, specs (JSON text)], the specs bound as text like
582
582
  * `scanRowBatchSql`.
583
583
  */
584
584
  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,
585
585
  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
586
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
586
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)
587
587
  CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
588
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
588
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
589
589
  AND commit_seq>s.after AND commit_seq<=s.through
590
590
  ORDER BY commit_seq LIMIT s.lim) c
591
591
  LEFT JOIN sync_commits m
592
592
  ON m.partition=$1 AND m.commit_seq=c.commit_seq
593
593
  LEFT JOIN sync_changes ch
594
- ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
594
+ ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
595
595
  ORDER BY s.i, c.commit_seq, ch.idx`;
596
596
  /**
597
597
  * PostgreSQL push read-ahead: every requested row id with its stored row
@@ -620,15 +620,15 @@ export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commi
620
620
  FROM json_each(?) AS v`;
621
621
  /**
622
622
  * PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
623
- * subscriptions of one table in a pull. `$2` is a JSON array of
624
- * `{i, var, vals}` records. Params: [partition, specs (JSON text), tbl].
623
+ * subscriptions of a pull, across all tables. `$2` is a JSON array of
624
+ * `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
625
625
  */
626
626
  export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
627
627
  (SELECT max(latest.seq) FROM (
628
628
  SELECT (SELECT max(commit_seq) FROM sync_change_scopes
629
- WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=v.value) AS seq
629
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq
630
630
  FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
631
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
631
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)
632
632
  ORDER BY s.i`;
633
633
  /** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
634
634
  export function selectRowScopesSql(table, dialect) {
@@ -7,6 +7,7 @@ export declare class SqliteBlobStore implements BlobStore {
7
7
  readonly db: SqliteDatabase;
8
8
  constructor(db?: SqliteDatabase | string);
9
9
  put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
10
+ delete(partition: string, blobId: string): Promise<void>;
10
11
  has(partition: string, blobId: string): Promise<boolean>;
11
12
  get(partition: string, blobId: string): Promise<{
12
13
  record: BlobRecord;
@@ -17,8 +17,15 @@ export class SqliteBlobStore {
17
17
  }
18
18
  async put(partition, blobId, bytes, nowMs, mediaType) {
19
19
  const existing = await this.get(partition, blobId);
20
- if (existing !== undefined)
21
- return existing.record;
20
+ if (existing !== undefined) {
21
+ if (existing.record.mediaType !== undefined || mediaType === undefined)
22
+ return existing.record;
23
+ // A re-upload names the media type an earlier upload left out.
24
+ this.db
25
+ .query('UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL')
26
+ .run(mediaType, partition, blobId);
27
+ return { ...existing.record, mediaType };
28
+ }
22
29
  const record = {
23
30
  blobId,
24
31
  partition,
@@ -33,6 +40,11 @@ export class SqliteBlobStore {
33
40
  .run(partition, blobId, bytes.length, mediaType ?? null, nowMs, bytes);
34
41
  return record;
35
42
  }
43
+ async delete(partition, blobId) {
44
+ this.db
45
+ .query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
46
+ .run(partition, blobId);
47
+ }
36
48
  async has(partition, blobId) {
37
49
  const row = this.db
38
50
  .query('SELECT 1 AS n FROM sync_blobs WHERE partition=? AND blob_id=?')
@@ -16,7 +16,7 @@
16
16
  * behavior is held key-for-key regardless.
17
17
  */
18
18
  import type { ScopeMap } from '@syncular/core';
19
- import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
19
+ import type { ScopeValueHistoryQuery, StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
20
20
  /**
21
21
  * Schema DDL, one statement per `;`-delimited chunk. Native SQLite applies
22
22
  * the whole string via `db.exec(SQLITE_DDL)`; D1 applies each statement
@@ -39,6 +39,16 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
39
39
  export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
40
40
  /** Split the DDL into individual statements (D1 applies them one by one). */
41
41
  export declare function sqliteDdlStatements(): string[];
42
+ /** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
43
+ export declare const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = "SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?";
44
+ /**
45
+ * §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
46
+ * records of the binding first, then the change scope entries whose commit
47
+ * no longer holds a change of the binding. Each returns one row per removed
48
+ * record (D1 batch results carry no reliable change count).
49
+ */
50
+ export declare const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL: readonly ["DELETE FROM sync_changes WHERE rowid IN (\n SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c\n ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value\n LIMIT ?7) RETURNING 1 AS removed", "DELETE FROM sync_change_scopes WHERE rowid IN (\n SELECT s.rowid FROM sync_change_scopes s\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND NOT EXISTS (\n SELECT 1 FROM sync_changes c\n WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq\n AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)\n LIMIT ?7) RETURNING 1 AS removed"];
51
+ export declare function eraseScopeValueHistoryParams(partition: string, query: ScopeValueHistoryQuery): [string, string, string, string, number, string, number];
42
52
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
43
53
  export declare function placeholders(count: number): string;
44
54
  /** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */
@@ -124,6 +124,41 @@ export function sqliteDdlStatements() {
124
124
  .map((statement) => statement.trim())
125
125
  .filter((statement) => statement.length > 0);
126
126
  }
127
+ /** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
128
+ export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = 'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
129
+ /**
130
+ * §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
131
+ * records of the binding first, then the change scope entries whose commit
132
+ * no longer holds a change of the binding. Each returns one row per removed
133
+ * record (D1 batch results carry no reliable change count).
134
+ */
135
+ export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
136
+ `DELETE FROM sync_changes WHERE rowid IN (
137
+ SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
138
+ ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
139
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
140
+ AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
141
+ LIMIT ?7) RETURNING 1 AS removed`,
142
+ `DELETE FROM sync_change_scopes WHERE rowid IN (
143
+ SELECT s.rowid FROM sync_change_scopes s
144
+ WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
145
+ AND s.commit_seq<=?5 AND NOT EXISTS (
146
+ SELECT 1 FROM sync_changes c
147
+ WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
148
+ AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
149
+ LIMIT ?7) RETURNING 1 AS removed`,
150
+ ];
151
+ export function eraseScopeValueHistoryParams(partition, query) {
152
+ return [
153
+ partition,
154
+ query.table,
155
+ query.variable,
156
+ query.value,
157
+ query.throughSeq,
158
+ `$.${JSON.stringify(query.variable)}`,
159
+ query.limit,
160
+ ];
161
+ }
127
162
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
128
163
  export function placeholders(count) {
129
164
  return new Array(count).fill('?').join(',');
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { ScopeMap } from '@syncular/core';
3
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
4
  import { type SqliteDatabase } from './sqlite-driver.js';
5
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
6
6
  export declare class SqliteServerStorage implements ServerStorage {
7
7
  #private;
8
8
  readonly db: SqliteDatabase;
@@ -67,6 +67,8 @@ export declare class SqliteServerStorage implements ServerStorage {
67
67
  readonly scopes: Record<string, string>;
68
68
  }[]>;
69
69
  listReferencedBlobIds(partition: string): Promise<string[]>;
70
+ listScopeValueRows(partition: string, query: ScopeValueQuery): Promise<string[]>;
71
+ eraseScopeValueHistory(partition: string, query: ScopeValueHistoryQuery): Promise<number>;
70
72
  listClientRecords(partition: string): Promise<ClientRecord[]>;
71
73
  listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
72
74
  getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;