@syncular/server 0.30.1 → 0.30.3

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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.30.1",
3
+ "version": "0.30.3",
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.1"
71
+ "@syncular/core": "0.30.3"
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>;