@syncular/server 0.30.2 → 0.30.4

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.
@@ -686,34 +686,41 @@ class PostgresTransaction {
686
686
  const commitSeq = asNumber(rows[0]?.commit_seq);
687
687
  if (commit.changes.length === 0)
688
688
  return commitSeq;
689
- // One statement for the whole commit: its changes, their inverted scope
690
- // entries, and the §5 delete tombstones ride one writable-CTE chain.
689
+ // One writable-CTE chain per chunk of changes: the changes, their
690
+ // inverted scope entries, and the §5 delete tombstones. A statement binds
691
+ // at most a 16-bit count of parameters, so a chunk carries
692
+ // PG_CHANGES_PER_STATEMENT changes; every chunk runs in the
693
+ // commit's transaction under the same commit_seq with its global idx.
691
694
  // Serialized scopes bind as text before parsing JSONB; drivers that
692
695
  // encode JSONB parameters would otherwise store the string as a scalar.
693
- // A commit can delete one row twice, so the tombstone insert keeps one
694
- // row per key (every candidate carries the same commit_seq).
695
- const params = [p, commitSeq];
696
- const values = commit.changes.map((change, idx) => {
697
- const base = params.length;
698
- params.push(idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
699
- return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
700
- });
701
- await q.query(`WITH inserted AS (
702
- INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
703
- VALUES ${values.join(',')}
704
- RETURNING partition, tbl, row_id, op, commit_seq, scopes
705
- ),
706
- scoped AS (
707
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
708
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
709
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
710
- ON CONFLICT DO NOTHING
711
- )
712
- INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
713
- SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
714
- FROM inserted WHERE op = 2
715
- ON CONFLICT (partition, tbl, row_id)
716
- DO UPDATE SET commit_seq = excluded.commit_seq`, params);
696
+ // A commit can delete one row twice, so a chunk's tombstone insert keeps
697
+ // one row per key and a later chunk updates it to the same commit_seq.
698
+ for (let first = 0; first < commit.changes.length; first += PG_CHANGES_PER_STATEMENT) {
699
+ const params = [p, commitSeq];
700
+ const values = commit.changes
701
+ .slice(first, first + PG_CHANGES_PER_STATEMENT)
702
+ .map((change, offset) => {
703
+ const base = params.length;
704
+ params.push(first + offset, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
705
+ return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
706
+ });
707
+ await q.query(`WITH inserted AS (
708
+ INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
709
+ VALUES ${values.join(',')}
710
+ RETURNING partition, tbl, row_id, op, commit_seq, scopes
711
+ ),
712
+ scoped AS (
713
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
714
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
715
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
716
+ ON CONFLICT DO NOTHING
717
+ )
718
+ INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
719
+ SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
720
+ FROM inserted WHERE op = 2
721
+ ON CONFLICT (partition, tbl, row_id)
722
+ DO UPDATE SET commit_seq = excluded.commit_seq`, params);
723
+ }
717
724
  return commitSeq;
718
725
  }
719
726
  async putPushResult(clientId, clientCommitId, result) {
@@ -776,6 +783,10 @@ class RollbackSignal extends Error {
776
783
  this.name = 'RollbackSignal';
777
784
  }
778
785
  }
786
+ /** Changes per sync_changes statement: 2 + 7 × 4,096 = 28,674 bound
787
+ * parameters. The Bind message counts parameters in 16 bits; PostgreSQL reads
788
+ * up to 65,535, and PGlite 0.5 corrupts its state above 32,767. */
789
+ const PG_CHANGES_PER_STATEMENT = 4096;
779
790
  export class PostgresServerStorage {
780
791
  #exec;
781
792
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
@@ -999,6 +1010,17 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
999
1010
  await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
1000
1011
  ON CONFLICT (id) DO UPDATE
1001
1012
  SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`, [schema.version, layoutsOf(schema)]);
1013
+ // A writer of the previous schema still running against this
1014
+ // database would append payloads in the previous layout. Its push
1015
+ // is refused by the serve gate, but a host's server-side
1016
+ // `begin()`/`appendCommit` reads no gate, so the bump raises the
1017
+ // writer fence of every partition to the new version.
1018
+ await client.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
1019
+ SELECT partition, $1 FROM sync_partitions
1020
+ ON CONFLICT (partition) DO UPDATE SET
1021
+ required_writer_version=GREATEST(
1022
+ sync_writer_fence.required_writer_version,
1023
+ EXCLUDED.required_writer_version)`, [schema.version]);
1002
1024
  // The host's declared checkpoints install in the schema-bump
1003
1025
  // transaction, so no observer sees the bumped marker without the
1004
1026
  // fence, or the fence without its declaration row.
@@ -412,6 +412,17 @@ END`);
412
412
  this.db
413
413
  .query('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
414
414
  .run(schema.version, layoutsOf(schema));
415
+ // A writer of the previous schema would append payloads in the
416
+ // previous layout; the bump fences every partition at the new
417
+ // version (see PostgresServerStorage.ensureSchema).
418
+ this.db
419
+ .query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
420
+ SELECT partition, ? FROM sync_partitions WHERE true
421
+ ON CONFLICT(partition) DO UPDATE SET
422
+ required_writer_version=max(
423
+ sync_writer_fence.required_writer_version,
424
+ excluded.required_writer_version)`)
425
+ .run(schema.version);
415
426
  // The host's declared checkpoints install in the schema-bump
416
427
  // transaction, so no observer sees the bumped marker without the
417
428
  // fence, or the fence without its declaration row.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.30.2",
3
+ "version": "0.30.4",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.30.2"
71
+ "@syncular/core": "0.30.4"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -1117,45 +1117,56 @@ class PostgresTransaction implements StorageTransaction {
1117
1117
  );
1118
1118
  const commitSeq = asNumber(rows[0]?.commit_seq);
1119
1119
  if (commit.changes.length === 0) return commitSeq;
1120
- // One statement for the whole commit: its changes, their inverted scope
1121
- // entries, and the §5 delete tombstones ride one writable-CTE chain.
1120
+ // One writable-CTE chain per chunk of changes: the changes, their
1121
+ // inverted scope entries, and the §5 delete tombstones. A statement binds
1122
+ // at most a 16-bit count of parameters, so a chunk carries
1123
+ // PG_CHANGES_PER_STATEMENT changes; every chunk runs in the
1124
+ // commit's transaction under the same commit_seq with its global idx.
1122
1125
  // Serialized scopes bind as text before parsing JSONB; drivers that
1123
1126
  // encode JSONB parameters would otherwise store the string as a scalar.
1124
- // A commit can delete one row twice, so the tombstone insert keeps one
1125
- // row per key (every candidate carries the same commit_seq).
1126
- const params: unknown[] = [p, commitSeq];
1127
- const values = commit.changes.map((change, idx) => {
1128
- const base = params.length;
1129
- params.push(
1130
- idx,
1131
- change.table,
1132
- change.rowId,
1133
- change.op === 'upsert' ? 1 : 2,
1134
- change.rowVersion ?? null,
1135
- JSON.stringify(change.scopes),
1136
- change.payload ?? null,
1127
+ // A commit can delete one row twice, so a chunk's tombstone insert keeps
1128
+ // one row per key and a later chunk updates it to the same commit_seq.
1129
+ for (
1130
+ let first = 0;
1131
+ first < commit.changes.length;
1132
+ first += PG_CHANGES_PER_STATEMENT
1133
+ ) {
1134
+ const params: unknown[] = [p, commitSeq];
1135
+ const values = commit.changes
1136
+ .slice(first, first + PG_CHANGES_PER_STATEMENT)
1137
+ .map((change, offset) => {
1138
+ const base = params.length;
1139
+ params.push(
1140
+ first + offset,
1141
+ change.table,
1142
+ change.rowId,
1143
+ change.op === 'upsert' ? 1 : 2,
1144
+ change.rowVersion ?? null,
1145
+ JSON.stringify(change.scopes),
1146
+ change.payload ?? null,
1147
+ );
1148
+ return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
1149
+ });
1150
+ await q.query(
1151
+ `WITH inserted AS (
1152
+ INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
1153
+ VALUES ${values.join(',')}
1154
+ RETURNING partition, tbl, row_id, op, commit_seq, scopes
1155
+ ),
1156
+ scoped AS (
1157
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
1158
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
1159
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
1160
+ ON CONFLICT DO NOTHING
1161
+ )
1162
+ INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
1163
+ SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
1164
+ FROM inserted WHERE op = 2
1165
+ ON CONFLICT (partition, tbl, row_id)
1166
+ DO UPDATE SET commit_seq = excluded.commit_seq`,
1167
+ params,
1137
1168
  );
1138
- return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
1139
- });
1140
- await q.query(
1141
- `WITH inserted AS (
1142
- INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
1143
- VALUES ${values.join(',')}
1144
- RETURNING partition, tbl, row_id, op, commit_seq, scopes
1145
- ),
1146
- scoped AS (
1147
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
1148
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
1149
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
1150
- ON CONFLICT DO NOTHING
1151
- )
1152
- INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
1153
- SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
1154
- FROM inserted WHERE op = 2
1155
- ON CONFLICT (partition, tbl, row_id)
1156
- DO UPDATE SET commit_seq = excluded.commit_seq`,
1157
- params,
1158
- );
1169
+ }
1159
1170
  return commitSeq;
1160
1171
  }
1161
1172
 
@@ -1234,6 +1245,11 @@ class RollbackSignal extends Error {
1234
1245
  }
1235
1246
  }
1236
1247
 
1248
+ /** Changes per sync_changes statement: 2 + 7 × 4,096 = 28,674 bound
1249
+ * parameters. The Bind message counts parameters in 16 bits; PostgreSQL reads
1250
+ * up to 65,535, and PGlite 0.5 corrupts its state above 32,767. */
1251
+ const PG_CHANGES_PER_STATEMENT = 4096;
1252
+
1237
1253
  /** One page read waiting in `PostgresServerStorage#batchedPage`. */
1238
1254
  interface PendingPage {
1239
1255
  readonly spec: Record<string, unknown>;
@@ -1542,6 +1558,20 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1542
1558
  SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`,
1543
1559
  [schema.version, layoutsOf(schema)],
1544
1560
  );
1561
+ // A writer of the previous schema still running against this
1562
+ // database would append payloads in the previous layout. Its push
1563
+ // is refused by the serve gate, but a host's server-side
1564
+ // `begin()`/`appendCommit` reads no gate, so the bump raises the
1565
+ // writer fence of every partition to the new version.
1566
+ await client.query(
1567
+ `INSERT INTO sync_writer_fence(partition, required_writer_version)
1568
+ SELECT partition, $1 FROM sync_partitions
1569
+ ON CONFLICT (partition) DO UPDATE SET
1570
+ required_writer_version=GREATEST(
1571
+ sync_writer_fence.required_writer_version,
1572
+ EXCLUDED.required_writer_version)`,
1573
+ [schema.version],
1574
+ );
1545
1575
  // The host's declared checkpoints install in the schema-bump
1546
1576
  // transaction, so no observer sees the bumped marker without the
1547
1577
  // fence, or the fence without its declaration row.
@@ -698,6 +698,19 @@ END`);
698
698
  'INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts',
699
699
  )
700
700
  .run(schema.version, layoutsOf(schema));
701
+ // A writer of the previous schema would append payloads in the
702
+ // previous layout; the bump fences every partition at the new
703
+ // version (see PostgresServerStorage.ensureSchema).
704
+ this.db
705
+ .query(
706
+ `INSERT INTO sync_writer_fence(partition, required_writer_version)
707
+ SELECT partition, ? FROM sync_partitions WHERE true
708
+ ON CONFLICT(partition) DO UPDATE SET
709
+ required_writer_version=max(
710
+ sync_writer_fence.required_writer_version,
711
+ excluded.required_writer_version)`,
712
+ )
713
+ .run(schema.version);
701
714
  // The host's declared checkpoints install in the schema-bump
702
715
  // transaction, so no observer sees the bumped marker without the
703
716
  // fence, or the fence without its declaration row.