@syncular/server-dialect-postgres 0.0.1 → 0.0.2-126

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.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @syncular/server-dialect-postgres
2
+
3
+ PostgreSQL dialect for the Syncular server sync schema and query patterns (commit log, change log, scopes, snapshot chunks, console tables).
4
+
5
+ Use this when your server is backed by Postgres.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @syncular/server-dialect-postgres
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { ensureSyncSchema } from '@syncular/server';
17
+ import { createPostgresServerDialect } from '@syncular/server-dialect-postgres';
18
+
19
+ const dialect = createPostgresServerDialect();
20
+ await ensureSyncSchema(db, dialect);
21
+ ```
22
+
23
+ ## Documentation
24
+
25
+ - Dialects: https://syncular.dev/docs/server/dialects
26
+ - Server setup: https://syncular.dev/docs/build/server-setup
27
+
28
+ ## Links
29
+
30
+ - GitHub: https://github.com/syncular/syncular
31
+ - Issues: https://github.com/syncular/syncular/issues
32
+
33
+ > Status: Alpha. APIs and storage layouts may change between releases.
package/dist/index.d.ts CHANGED
@@ -10,79 +10,46 @@
10
10
  * - sync_changes: change log (JSONB scopes for filtering)
11
11
  * - sync_client_cursors: per-client cursor tracking (pruning/observability)
12
12
  */
13
- import type { ScopeValues, StoredScopes, SyncOp } from '@syncular/core';
14
- import type { DbExecutor, ServerSyncDialect } from '@syncular/server';
15
- import type { SyncChangeRow, SyncCommitRow, SyncCoreDb } from '@syncular/server/schema';
16
- import type { Kysely, Transaction } from 'kysely';
17
- export declare class PostgresServerSyncDialect implements ServerSyncDialect {
13
+ import type { ScopeValues, StoredScopes } from '@syncular/core';
14
+ import type { DbExecutor } from '@syncular/server';
15
+ import { BaseServerSyncDialect, type IncrementalPullRow, type IncrementalPullRowsArgs } from '@syncular/server';
16
+ import type { SyncChangeRow, SyncCoreDb } from '@syncular/server/schema';
17
+ import type { Kysely, RawBuilder, Transaction } from 'kysely';
18
+ export declare class PostgresServerSyncDialect extends BaseServerSyncDialect {
18
19
  readonly name: "postgres";
19
20
  readonly supportsForUpdate = true;
20
21
  readonly supportsSavepoints = true;
22
+ protected buildNumberListFilter(values: number[]): RawBuilder<unknown>;
23
+ protected buildStringListFilter(values: string[]): RawBuilder<unknown>;
21
24
  ensureSyncSchema<DB extends SyncCoreDb>(db: Kysely<DB>): Promise<void>;
22
25
  executeInTransaction<DB extends SyncCoreDb, T>(db: Kysely<DB>, fn: (executor: DbExecutor<DB>) => Promise<T>): Promise<T>;
23
26
  setRepeatableRead<DB extends SyncCoreDb>(trx: DbExecutor<DB>): Promise<void>;
24
- readMaxCommitSeq<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>): Promise<number>;
25
- readMinCommitSeq<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>): Promise<number>;
26
27
  readCommitSeqsForPull<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
27
28
  cursor: number;
28
29
  limitCommits: number;
29
30
  tables: string[];
31
+ partitionId?: string;
30
32
  }): Promise<number[]>;
31
- readCommits<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, commitSeqs: number[]): Promise<SyncCommitRow[]>;
32
- readChangesForCommits<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
33
- commitSeqs: number[];
34
- table: string;
35
- scopes: ScopeValues;
36
- }): Promise<SyncChangeRow[]>;
37
- readIncrementalPullRows<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
38
- table: string;
39
- scopes: ScopeValues;
40
- cursor: number;
41
- limitCommits: number;
42
- }): Promise<Array<{
43
- commit_seq: number;
44
- actor_id: string;
45
- created_at: string;
46
- change_id: number;
47
- table: string;
48
- row_id: string;
49
- op: SyncOp;
50
- row_json: unknown | null;
51
- row_version: number | null;
52
- scopes: StoredScopes;
53
- }>>;
54
- streamIncrementalPullRows<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
55
- table: string;
56
- scopes: ScopeValues;
57
- cursor: number;
58
- limitCommits: number;
59
- batchSize?: number;
60
- }): AsyncGenerator<{
61
- commit_seq: number;
62
- actor_id: string;
63
- created_at: string;
64
- change_id: number;
65
- table: string;
66
- row_id: string;
67
- op: SyncOp;
68
- row_json: unknown | null;
69
- row_version: number | null;
70
- scopes: StoredScopes;
71
- }>;
72
- compactChanges<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
73
- fullHistoryHours: number;
74
- }): Promise<number>;
75
33
  recordClientCursor<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, args: {
34
+ partitionId?: string;
76
35
  clientId: string;
77
36
  actorId: string;
78
37
  cursor: number;
79
38
  effectiveScopes: ScopeValues;
80
39
  }): Promise<void>;
40
+ readChangesForCommits<DB extends SyncCoreDb>(db: DbExecutor<DB>, args: {
41
+ commitSeqs: number[];
42
+ table: string;
43
+ scopes: ScopeValues;
44
+ partitionId?: string;
45
+ }): Promise<SyncChangeRow[]>;
46
+ protected readIncrementalPullRowsBatch<DB extends SyncCoreDb>(db: DbExecutor<DB>, args: Omit<IncrementalPullRowsArgs, 'batchSize'>): Promise<IncrementalPullRow[]>;
47
+ compactChanges<DB extends SyncCoreDb>(db: DbExecutor<DB>, args: {
48
+ fullHistoryHours: number;
49
+ }): Promise<number>;
81
50
  scopesToDb(scopes: StoredScopes): StoredScopes;
82
- dbToScopes(value: unknown): StoredScopes;
83
51
  dbToArray(value: unknown): string[];
84
52
  arrayToDb(values: string[]): string[];
85
- readAffectedTablesFromChanges<DB extends SyncCoreDb>(db: Kysely<DB> | Transaction<DB>, commitSeq: number): Promise<string[]>;
86
53
  ensureConsoleSchema<DB extends SyncCoreDb>(db: Kysely<DB>): Promise<void>;
87
54
  private ensureIndex;
88
55
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -10,46 +10,29 @@
10
10
  * - sync_changes: change log (JSONB scopes for filtering)
11
11
  * - sync_client_cursors: per-client cursor tracking (pruning/observability)
12
12
  */
13
+ import { BaseServerSyncDialect, coerceIsoString, coerceNumber, parseScopes, } from '@syncular/server';
13
14
  import { sql } from 'kysely';
14
- function coerceNumber(value) {
15
- if (value === null || value === undefined)
16
- return null;
17
- if (typeof value === 'number')
18
- return Number.isFinite(value) ? value : null;
19
- if (typeof value === 'bigint')
20
- return Number.isFinite(Number(value)) ? Number(value) : null;
21
- if (typeof value === 'string') {
22
- const n = Number(value);
23
- return Number.isFinite(n) ? n : null;
24
- }
25
- return null;
26
- }
27
- function coerceIsoString(value) {
28
- if (typeof value === 'string')
29
- return value;
30
- if (value instanceof Date)
31
- return value.toISOString();
32
- return String(value);
15
+ function isActiveTransaction(db) {
16
+ return db.isTransaction === true;
33
17
  }
34
- function parseScopes(value) {
35
- if (value === null || value === undefined)
36
- return {};
37
- if (typeof value === 'object' && !Array.isArray(value)) {
38
- const result = {};
39
- for (const [k, v] of Object.entries(value)) {
40
- if (typeof v === 'string') {
41
- result[k] = v;
42
- }
43
- }
44
- return result;
45
- }
46
- return {};
18
+ function createSavepointName() {
19
+ const randomPart = Math.floor(Math.random() * 1_000_000_000).toString(36);
20
+ return `syncular_sp_${Date.now().toString(36)}_${randomPart}`;
47
21
  }
48
- export class PostgresServerSyncDialect {
22
+ export class PostgresServerSyncDialect extends BaseServerSyncDialect {
49
23
  name = 'postgres';
50
24
  supportsForUpdate = true;
51
25
  supportsSavepoints = true;
52
26
  // ===========================================================================
27
+ // SQL Fragment Hooks
28
+ // ===========================================================================
29
+ buildNumberListFilter(values) {
30
+ return sql `= ANY(${values}::bigint[])`;
31
+ }
32
+ buildStringListFilter(values) {
33
+ return sql `= ANY(${values}::text[])`;
34
+ }
35
+ // ===========================================================================
53
36
  // Schema Setup
54
37
  // ===========================================================================
55
38
  async ensureSyncSchema(db) {
@@ -57,6 +40,7 @@ export class PostgresServerSyncDialect {
57
40
  .createTable('sync_commits')
58
41
  .ifNotExists()
59
42
  .addColumn('commit_seq', 'bigserial', (col) => col.primaryKey())
43
+ .addColumn('partition_id', 'text', (col) => col.notNull().defaultTo('default'))
60
44
  .addColumn('actor_id', 'text', (col) => col.notNull())
61
45
  .addColumn('client_id', 'text', (col) => col.notNull())
62
46
  .addColumn('client_commit_id', 'text', (col) => col.notNull())
@@ -71,32 +55,43 @@ export class PostgresServerSyncDialect {
71
55
  ADD COLUMN IF NOT EXISTS change_count integer NOT NULL DEFAULT 0`.execute(db);
72
56
  await sql `ALTER TABLE sync_commits
73
57
  ADD COLUMN IF NOT EXISTS affected_tables text[] NOT NULL DEFAULT ARRAY[]::text[]`.execute(db);
58
+ await sql `ALTER TABLE sync_commits
59
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(db);
60
+ await sql `DROP INDEX IF EXISTS idx_sync_commits_client_commit`.execute(db);
74
61
  await db.schema
75
62
  .createIndex('idx_sync_commits_client_commit')
76
63
  .ifNotExists()
77
64
  .on('sync_commits')
78
- .columns(['client_id', 'client_commit_id'])
65
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
79
66
  .unique()
80
67
  .execute();
81
68
  // Table-based commit routing index
82
69
  await db.schema
83
70
  .createTable('sync_table_commits')
84
71
  .ifNotExists()
72
+ .addColumn('partition_id', 'text', (col) => col.notNull().defaultTo('default'))
85
73
  .addColumn('table', 'text', (col) => col.notNull())
86
74
  .addColumn('commit_seq', 'bigint', (col) => col.notNull().references('sync_commits.commit_seq').onDelete('cascade'))
87
- .addPrimaryKeyConstraint('sync_table_commits_pk', ['table', 'commit_seq'])
75
+ .addPrimaryKeyConstraint('sync_table_commits_pk', [
76
+ 'partition_id',
77
+ 'table',
78
+ 'commit_seq',
79
+ ])
88
80
  .execute();
81
+ await sql `ALTER TABLE sync_table_commits
82
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(db);
89
83
  await db.schema
90
84
  .createIndex('idx_sync_table_commits_commit_seq')
91
85
  .ifNotExists()
92
86
  .on('sync_table_commits')
93
- .columns(['commit_seq'])
87
+ .columns(['partition_id', 'commit_seq'])
94
88
  .execute();
95
89
  // Changes table with JSONB scopes
96
90
  await db.schema
97
91
  .createTable('sync_changes')
98
92
  .ifNotExists()
99
93
  .addColumn('change_id', 'bigserial', (col) => col.primaryKey())
94
+ .addColumn('partition_id', 'text', (col) => col.notNull().defaultTo('default'))
100
95
  .addColumn('commit_seq', 'bigint', (col) => col.notNull().references('sync_commits.commit_seq').onDelete('cascade'))
101
96
  .addColumn('table', 'text', (col) => col.notNull())
102
97
  .addColumn('row_id', 'text', (col) => col.notNull())
@@ -105,28 +100,37 @@ export class PostgresServerSyncDialect {
105
100
  .addColumn('row_version', 'bigint')
106
101
  .addColumn('scopes', 'jsonb', (col) => col.notNull())
107
102
  .execute();
103
+ await sql `ALTER TABLE sync_changes
104
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(db);
108
105
  await db.schema
109
106
  .createIndex('idx_sync_changes_commit_seq')
110
107
  .ifNotExists()
111
108
  .on('sync_changes')
112
- .columns(['commit_seq'])
109
+ .columns(['partition_id', 'commit_seq'])
113
110
  .execute();
114
111
  await db.schema
115
112
  .createIndex('idx_sync_changes_table')
116
113
  .ifNotExists()
117
114
  .on('sync_changes')
118
- .columns(['table'])
115
+ .columns(['partition_id', 'table'])
119
116
  .execute();
120
117
  await this.ensureIndex(db, 'idx_sync_changes_scopes', 'CREATE INDEX idx_sync_changes_scopes ON sync_changes USING GIN (scopes)');
121
118
  await db.schema
122
119
  .createTable('sync_client_cursors')
123
120
  .ifNotExists()
124
- .addColumn('client_id', 'text', (col) => col.primaryKey())
121
+ .addColumn('partition_id', 'text', (col) => col.notNull().defaultTo('default'))
122
+ .addColumn('client_id', 'text', (col) => col.notNull())
125
123
  .addColumn('actor_id', 'text', (col) => col.notNull())
126
124
  .addColumn('cursor', 'bigint', (col) => col.notNull().defaultTo(0))
127
125
  .addColumn('effective_scopes', 'jsonb', (col) => col.notNull().defaultTo(sql `'{}'::jsonb`))
128
126
  .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql `now()`))
127
+ .addPrimaryKeyConstraint('sync_client_cursors_pk', [
128
+ 'partition_id',
129
+ 'client_id',
130
+ ])
129
131
  .execute();
132
+ await sql `ALTER TABLE sync_client_cursors
133
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(db);
130
134
  await db.schema
131
135
  .createIndex('idx_sync_client_cursors_updated_at')
132
136
  .ifNotExists()
@@ -137,6 +141,7 @@ export class PostgresServerSyncDialect {
137
141
  .createTable('sync_snapshot_chunks')
138
142
  .ifNotExists()
139
143
  .addColumn('chunk_id', 'text', (col) => col.primaryKey())
144
+ .addColumn('partition_id', 'text', (col) => col.notNull().defaultTo('default'))
140
145
  .addColumn('scope_key', 'text', (col) => col.notNull())
141
146
  .addColumn('scope', 'text', (col) => col.notNull())
142
147
  .addColumn('as_of_commit_seq', 'bigint', (col) => col.notNull())
@@ -151,6 +156,8 @@ export class PostgresServerSyncDialect {
151
156
  .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql `now()`))
152
157
  .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
153
158
  .execute();
159
+ await sql `ALTER TABLE sync_snapshot_chunks
160
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(db);
154
161
  await db.schema
155
162
  .createIndex('idx_sync_snapshot_chunks_expires_at')
156
163
  .ifNotExists()
@@ -162,6 +169,7 @@ export class PostgresServerSyncDialect {
162
169
  .ifNotExists()
163
170
  .on('sync_snapshot_chunks')
164
171
  .columns([
172
+ 'partition_id',
165
173
  'scope_key',
166
174
  'scope',
167
175
  'as_of_commit_seq',
@@ -177,36 +185,39 @@ export class PostgresServerSyncDialect {
177
185
  // Transaction Control
178
186
  // ===========================================================================
179
187
  async executeInTransaction(db, fn) {
188
+ if (isActiveTransaction(db)) {
189
+ const savepoint = createSavepointName();
190
+ await sql.raw(`SAVEPOINT ${savepoint}`).execute(db);
191
+ try {
192
+ const result = await fn(db);
193
+ await sql.raw(`RELEASE SAVEPOINT ${savepoint}`).execute(db);
194
+ return result;
195
+ }
196
+ catch (error) {
197
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${savepoint}`).execute(db);
198
+ await sql.raw(`RELEASE SAVEPOINT ${savepoint}`).execute(db);
199
+ throw error;
200
+ }
201
+ }
180
202
  return db.transaction().execute(fn);
181
203
  }
182
204
  async setRepeatableRead(trx) {
183
205
  await sql `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`.execute(trx);
184
206
  }
185
207
  // ===========================================================================
186
- // Commit/Change Log Queries
208
+ // Overrides (dialect-specific optimizations / casts)
187
209
  // ===========================================================================
188
- async readMaxCommitSeq(db) {
189
- const res = await sql `
190
- SELECT max(commit_seq) as max_seq
191
- FROM sync_commits
192
- `.execute(db);
193
- return coerceNumber(res.rows[0]?.max_seq) ?? 0;
194
- }
195
- async readMinCommitSeq(db) {
196
- const res = await sql `
197
- SELECT min(commit_seq) as min_seq
198
- FROM sync_commits
199
- `.execute(db);
200
- return coerceNumber(res.rows[0]?.min_seq) ?? 0;
201
- }
202
210
  async readCommitSeqsForPull(db, args) {
211
+ const partitionId = args.partitionId ?? 'default';
203
212
  if (args.tables.length === 0)
204
213
  return [];
214
+ // Single-table fast path: skip DISTINCT since (partition_id, table, commit_seq) is PK
205
215
  if (args.tables.length === 1) {
206
216
  const res = await sql `
207
217
  SELECT commit_seq
208
218
  FROM sync_table_commits
209
- WHERE "table" = ${args.tables[0]}
219
+ WHERE partition_id = ${partitionId}
220
+ AND "table" = ${args.tables[0]}
210
221
  AND commit_seq > ${args.cursor}
211
222
  ORDER BY commit_seq ASC
212
223
  LIMIT ${args.limitCommits}
@@ -215,43 +226,34 @@ export class PostgresServerSyncDialect {
215
226
  .map((r) => coerceNumber(r.commit_seq))
216
227
  .filter((n) => typeof n === 'number' && Number.isFinite(n) && n > args.cursor);
217
228
  }
218
- const res = await sql `
219
- SELECT DISTINCT commit_seq
220
- FROM sync_table_commits
221
- WHERE "table" = ANY(${args.tables}::text[])
222
- AND commit_seq > ${args.cursor}
223
- ORDER BY commit_seq ASC
224
- LIMIT ${args.limitCommits}
225
- `.execute(db);
226
- return res.rows
227
- .map((r) => coerceNumber(r.commit_seq))
228
- .filter((n) => typeof n === 'number' && Number.isFinite(n) && n > args.cursor);
229
+ // Multi-table: use ANY() with DISTINCT
230
+ return super.readCommitSeqsForPull(db, args);
229
231
  }
230
- async readCommits(db, commitSeqs) {
231
- if (commitSeqs.length === 0)
232
- return [];
233
- const res = await sql `
234
- SELECT commit_seq, actor_id, created_at, result_json
235
- FROM sync_commits
236
- WHERE commit_seq = ANY(${commitSeqs}::bigint[])
237
- ORDER BY commit_seq ASC
232
+ async recordClientCursor(db, args) {
233
+ const partitionId = args.partitionId ?? 'default';
234
+ const now = new Date().toISOString();
235
+ const scopesJson = JSON.stringify(args.effectiveScopes);
236
+ await sql `
237
+ INSERT INTO sync_client_cursors (partition_id, client_id, actor_id, cursor, effective_scopes, updated_at)
238
+ VALUES (${partitionId}, ${args.clientId}, ${args.actorId}, ${args.cursor}, ${scopesJson}::jsonb, ${now})
239
+ ON CONFLICT(partition_id, client_id) DO UPDATE SET
240
+ actor_id = ${args.actorId},
241
+ cursor = ${args.cursor},
242
+ effective_scopes = ${scopesJson}::jsonb,
243
+ updated_at = ${now}
238
244
  `.execute(db);
239
- return res.rows.map((row) => ({
240
- commit_seq: coerceNumber(row.commit_seq) ?? 0,
241
- actor_id: row.actor_id,
242
- created_at: coerceIsoString(row.created_at),
243
- result_json: row.result_json ?? null,
244
- }));
245
245
  }
246
+ // ===========================================================================
247
+ // Commit/Change Log Queries (dialect-specific)
248
+ // ===========================================================================
246
249
  async readChangesForCommits(db, args) {
250
+ const partitionId = args.partitionId ?? 'default';
247
251
  if (args.commitSeqs.length === 0)
248
252
  return [];
249
253
  // Build JSONB containment conditions for scope filtering
250
- // For each scope key/value, we need: scopes->>'key' = 'value' OR scopes->>'key' IN (values)
251
254
  const scopeConditions = [];
252
255
  for (const [key, value] of Object.entries(args.scopes)) {
253
256
  if (Array.isArray(value)) {
254
- // OR condition for array values
255
257
  scopeConditions.push(sql `scopes->>${key} = ANY(${value}::text[])`);
256
258
  }
257
259
  else {
@@ -262,6 +264,7 @@ export class PostgresServerSyncDialect {
262
264
  SELECT commit_seq, "table", row_id, op, row_json, row_version, scopes
263
265
  FROM sync_changes
264
266
  WHERE commit_seq = ANY(${args.commitSeqs}::bigint[])
267
+ AND partition_id = ${partitionId}
265
268
  AND "table" = ${args.table}
266
269
  `;
267
270
  if (scopeConditions.length > 0) {
@@ -270,6 +273,7 @@ export class PostgresServerSyncDialect {
270
273
  SELECT commit_seq, "table", row_id, op, row_json, row_version, scopes
271
274
  FROM sync_changes
272
275
  WHERE commit_seq = ANY(${args.commitSeqs}::bigint[])
276
+ AND partition_id = ${partitionId}
273
277
  AND "table" = ${args.table}
274
278
  AND (${scopeFilter})
275
279
  ORDER BY commit_seq ASC, change_id ASC
@@ -286,7 +290,8 @@ export class PostgresServerSyncDialect {
286
290
  scopes: parseScopes(row.scopes),
287
291
  }));
288
292
  }
289
- async readIncrementalPullRows(db, args) {
293
+ async readIncrementalPullRowsBatch(db, args) {
294
+ const partitionId = args.partitionId ?? 'default';
290
295
  const limitCommits = Math.max(1, Math.min(500, args.limitCommits));
291
296
  // Build scope filter conditions
292
297
  const scopeConditions = [];
@@ -306,12 +311,15 @@ export class PostgresServerSyncDialect {
306
311
  SELECT DISTINCT tc.commit_seq
307
312
  FROM sync_table_commits tc
308
313
  JOIN sync_commits cm ON cm.commit_seq = tc.commit_seq
309
- WHERE tc."table" = ${args.table}
314
+ WHERE tc.partition_id = ${partitionId}
315
+ AND tc."table" = ${args.table}
316
+ AND cm.partition_id = ${partitionId}
310
317
  AND tc.commit_seq > ${args.cursor}
311
318
  AND EXISTS (
312
319
  SELECT 1
313
320
  FROM sync_changes c
314
321
  WHERE c.commit_seq = tc.commit_seq
322
+ AND c.partition_id = ${partitionId}
315
323
  AND c."table" = ${args.table}
316
324
  AND (${scopeFilter})
317
325
  )
@@ -332,7 +340,9 @@ export class PostgresServerSyncDialect {
332
340
  FROM commit_seqs cs
333
341
  JOIN sync_commits cm ON cm.commit_seq = cs.commit_seq
334
342
  JOIN sync_changes c ON c.commit_seq = cs.commit_seq
335
- WHERE c."table" = ${args.table}
343
+ WHERE cm.partition_id = ${partitionId}
344
+ AND c.partition_id = ${partitionId}
345
+ AND c."table" = ${args.table}
336
346
  AND (${scopeFilter})
337
347
  ORDER BY cm.commit_seq ASC, c.change_id ASC
338
348
  `.execute(db);
@@ -349,26 +359,6 @@ export class PostgresServerSyncDialect {
349
359
  scopes: parseScopes(row.scopes),
350
360
  }));
351
361
  }
352
- async *streamIncrementalPullRows(db, args) {
353
- // PostgreSQL: use batching approach (could use pg-query-stream for true streaming)
354
- const batchSize = Math.min(100, args.batchSize ?? 100);
355
- let processed = 0;
356
- while (processed < args.limitCommits) {
357
- const batch = await this.readIncrementalPullRows(db, {
358
- ...args,
359
- limitCommits: Math.min(batchSize, args.limitCommits - processed),
360
- cursor: args.cursor + processed,
361
- });
362
- if (batch.length === 0)
363
- break;
364
- for (const row of batch) {
365
- yield row;
366
- }
367
- processed += batch.length;
368
- if (batch.length < batchSize)
369
- break;
370
- }
371
- }
372
362
  async compactChanges(db, args) {
373
363
  const cutoffIso = new Date(Date.now() - args.fullHistoryHours * 60 * 60 * 1000).toISOString();
374
364
  const res = await sql `
@@ -376,7 +366,7 @@ export class PostgresServerSyncDialect {
376
366
  SELECT
377
367
  c.change_id,
378
368
  row_number() OVER (
379
- PARTITION BY c."table", c.row_id, c.scopes
369
+ PARTITION BY c.partition_id, c."table", c.row_id, c.scopes
380
370
  ORDER BY c.commit_seq DESC, c.change_id DESC
381
371
  ) AS rn
382
372
  FROM sync_changes c
@@ -391,42 +381,25 @@ export class PostgresServerSyncDialect {
391
381
  await sql `
392
382
  DELETE FROM sync_table_commits tc
393
383
  USING sync_commits cm
394
- WHERE cm.commit_seq = tc.commit_seq
395
- AND cm.created_at < ${cutoffIso}
384
+ WHERE cm.commit_seq = tc.commit_seq
385
+ AND cm.partition_id = tc.partition_id
386
+ AND cm.created_at < ${cutoffIso}
396
387
  AND NOT EXISTS (
397
388
  SELECT 1
398
389
  FROM sync_changes c
399
390
  WHERE c.commit_seq = tc.commit_seq
391
+ AND c.partition_id = tc.partition_id
400
392
  AND c."table" = tc."table"
401
393
  )
402
394
  `.execute(db);
403
395
  return deletedChanges;
404
396
  }
405
397
  // ===========================================================================
406
- // Client Cursor Recording
407
- // ===========================================================================
408
- async recordClientCursor(db, args) {
409
- const now = new Date().toISOString();
410
- const scopesJson = JSON.stringify(args.effectiveScopes);
411
- await sql `
412
- INSERT INTO sync_client_cursors (client_id, actor_id, cursor, effective_scopes, updated_at)
413
- VALUES (${args.clientId}, ${args.actorId}, ${args.cursor}, ${scopesJson}::jsonb, ${now})
414
- ON CONFLICT(client_id) DO UPDATE SET
415
- actor_id = ${args.actorId},
416
- cursor = ${args.cursor},
417
- effective_scopes = ${scopesJson}::jsonb,
418
- updated_at = ${now}
419
- `.execute(db);
420
- }
421
- // ===========================================================================
422
398
  // Scope Conversion Helpers
423
399
  // ===========================================================================
424
400
  scopesToDb(scopes) {
425
401
  return scopes;
426
402
  }
427
- dbToScopes(value) {
428
- return parseScopes(value);
429
- }
430
403
  dbToArray(value) {
431
404
  if (Array.isArray(value)) {
432
405
  return value.filter((k) => typeof k === 'string');
@@ -436,16 +409,6 @@ export class PostgresServerSyncDialect {
436
409
  arrayToDb(values) {
437
410
  return values.filter((v) => v.length > 0);
438
411
  }
439
- async readAffectedTablesFromChanges(db, commitSeq) {
440
- const res = await sql `
441
- SELECT DISTINCT "table"
442
- FROM sync_changes
443
- WHERE commit_seq = ${commitSeq}
444
- `.execute(db);
445
- return res.rows
446
- .map((r) => r.table)
447
- .filter((t) => typeof t === 'string' && t.length > 0);
448
- }
449
412
  // ===========================================================================
450
413
  // Console Schema (Request Events)
451
414
  // ===========================================================================