@syncular/server 0.38.0 → 0.39.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.
@@ -566,26 +566,31 @@ export function upsertValues(
566
566
  ];
567
567
  }
568
568
 
569
- function placeholderList(count: number, dialect: RelationalDialect): string {
570
- if (dialect === 'sqlite') return new Array(count).fill('?').join(',');
571
- const out: string[] = [];
572
- for (let i = 1; i <= count; i++) out.push(`$${i}`);
573
- return out.join(',');
574
- }
575
-
576
- /** Upsert statement text (bind with `upsertValues`). */
569
+ /**
570
+ * Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
571
+ * rows in one statement, bound as consecutive `upsertValues` lists; their
572
+ * row ids must be distinct.
573
+ */
577
574
  export function upsertSql(
578
575
  table: CompiledTable,
579
576
  dialect: RelationalDialect,
577
+ rows = 1,
580
578
  ): string {
581
579
  const names = tableColumnNames(table);
582
580
  const quoted = names.map((name) => quoteIdent(name)).join(', ');
583
- const values = placeholderList(names.length, dialect);
581
+ const values =
582
+ dialect === 'sqlite'
583
+ ? `(${new Array(names.length).fill('?').join(',')})`
584
+ : Array.from(
585
+ { length: rows },
586
+ (_, row) =>
587
+ `(${names.map((_, column) => `$${row * names.length + column + 1}`).join(',')})`,
588
+ ).join(',');
584
589
  const updates = names
585
590
  .slice(2) // partition + row id are the conflict key
586
591
  .map((name) => `${quoteIdent(name)}=excluded.${quoteIdent(name)}`)
587
592
  .join(', ');
588
- return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES (${values})
593
+ return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES ${values}
589
594
  ON CONFLICT (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)}) DO UPDATE SET ${updates}`;
590
595
  }
591
596
 
@@ -742,26 +747,40 @@ export function commitWindowPageSql(
742
747
 
743
748
  /**
744
749
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
745
- * page of every subscription scanning `table` in a pull. `$2` is a JSON array
746
- * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
747
- * index-first candidate subquery through a LATERAL join, so every
748
- * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
749
- * by (i, row_id).
750
+ * page of every subscription scanning any table in a pull. `$2` is a JSON
751
+ * array of `{i, tbl, var, vals, after, lim}` records; each record runs the
752
+ * same index-first candidate subquery through a LATERAL join, so every
753
+ * subscription keeps its own keyset cursor and LIMIT. The candidate side is
754
+ * table-agnostic (a spec names its table), so the statement joins the row
755
+ * table once per table present in the batch through a UNION ALL branch that
756
+ * keeps only its own candidates. Rows arrive ordered by (i, row_id).
750
757
  *
751
758
  * Params: [partition, specs (JSON text)]. The specs bind as text before
752
759
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
753
760
  * parameter (Bun.sql) cannot turn the array into a JSON string.
754
761
  */
755
- export function scanRowBatchSql(table: CompiledTable): string {
756
- 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
757
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
758
- CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
759
- WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
760
- AND row_id>s.after
761
- ORDER BY row_id LIMIT s.lim) c
762
- LEFT JOIN ${quoteIdent(table.name)} r
763
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
764
- ORDER BY s.i, c.row_id`;
762
+ export function scanRowBatchSql(tables: readonly CompiledTable[]): string {
763
+ // One branch per table, each keeping only its own candidates and selecting
764
+ // the same meta columns so the caller decodes one row shape.
765
+ const projection = tables
766
+ .map((table) => {
767
+ const quoted = quoteIdent(table.name);
768
+ return `SELECT c.sub 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
769
+ FROM cand c
770
+ LEFT JOIN ${quoted} r
771
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
772
+ WHERE c.tbl='${table.name.replaceAll("'", "''")}'`;
773
+ })
774
+ .join('\n UNION ALL ');
775
+ return `WITH cand AS (
776
+ SELECT s.i AS sub, c.row_id AS row_id, s.tbl AS tbl
777
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
778
+ CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
779
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
780
+ AND row_id>s.after
781
+ ORDER BY row_id LIMIT s.lim) c)
782
+ ${projection}
783
+ ORDER BY sub, row_id`;
765
784
  }
766
785
 
767
786
  /**
@@ -1054,30 +1073,17 @@ export function selectRowsForRewriteSql(
1054
1073
  }
1055
1074
 
1056
1075
  /**
1057
- * Rewrite one row during a migration: refresh the projection columns (when
1076
+ * SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
1058
1077
  * materialized) and the payload. Bind with `rewriteValues`.
1059
1078
  */
1060
- export function rewriteRowSql(
1061
- table: CompiledTable,
1062
- dialect: RelationalDialect,
1063
- ): string {
1064
- const sets: string[] = [];
1065
- let i = 1;
1066
- if (table.materialize) {
1067
- for (const column of table.columns) {
1068
- sets.push(
1069
- `${quoteIdent(column.name)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
1070
- );
1071
- i++;
1072
- }
1073
- }
1074
- sets.push(
1075
- `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
1076
- );
1077
- i++;
1078
- const wherePartition = dialect === 'sqlite' ? '?' : `$${i}`;
1079
- const whereRowId = dialect === 'sqlite' ? '?' : `$${i + 1}`;
1080
- return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${wherePartition} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${whereRowId}`;
1079
+ export function rewriteRowSql(table: CompiledTable): string {
1080
+ const sets = [
1081
+ ...(table.materialize
1082
+ ? table.columns.map((column) => `${quoteIdent(column.name)}=?`)
1083
+ : []),
1084
+ `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=?`,
1085
+ ];
1086
+ return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?`;
1081
1087
  }
1082
1088
 
1083
1089
  /** Bind values for `rewriteRowSql` from the (already migrated) payload. */
@@ -656,8 +656,6 @@ END`);
656
656
  // rewrite stored rows (payload re-encode for layout changes, and/or
657
657
  // projection backfill for flipped-on materialization). One
658
658
  // transaction: a failed bump leaves no half-state.
659
- const layouts = parseLayouts(marker?.layouts);
660
- const retiredTables = retiredTableNames(schema, layouts);
661
659
  const existing = new Map<string, ReadonlySet<string>>();
662
660
  const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
663
661
  for (const table of schema.tables.values()) {
@@ -693,6 +691,25 @@ END`);
693
691
  await this.#serializeWrite(() => {
694
692
  this.db.exec('BEGIN IMMEDIATE');
695
693
  try {
694
+ // Another process sharing the database may have bumped between the
695
+ // marker read above and the writer lock; the locked marker decides
696
+ // (SPEC "Schema upgrade writer fence").
697
+ const locked = this.db
698
+ .query<{ schema_version: number; layouts: string }, []>(
699
+ 'SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1',
700
+ )
701
+ .get();
702
+ if (locked !== null && locked.schema_version > schema.version) {
703
+ throw new Error(
704
+ `stored schema version ${locked.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
705
+ );
706
+ }
707
+ if (locked !== null && locked.schema_version === schema.version) {
708
+ this.db.exec('COMMIT');
709
+ return;
710
+ }
711
+ const layouts = parseLayouts(locked?.layouts);
712
+ const retiredTables = retiredTableNames(schema, layouts);
696
713
  for (const tableName of retiredTables) {
697
714
  this.db
698
715
  .query('DELETE FROM sync_row_scopes WHERE tbl=?')
@@ -880,7 +897,7 @@ END`);
880
897
  oldLayout: readonly StoredColumnLayout[] | undefined,
881
898
  ): void {
882
899
  const select = selectRowsForRewriteSql(table, 'sqlite');
883
- const update = this.db.query(rewriteRowSql(table, 'sqlite'));
900
+ const update = this.db.query(rewriteRowSql(table));
884
901
  const BATCH = 500;
885
902
  let afterPartition = '';
886
903
  let afterRowId = '';