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

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/src/index.ts CHANGED
@@ -12,52 +12,47 @@
12
12
  */
13
13
 
14
14
  import type { ScopeValues, StoredScopes, SyncOp } from '@syncular/core';
15
- import type { DbExecutor, ServerSyncDialect } from '@syncular/server';
16
- import type {
17
- SyncChangeRow,
18
- SyncCommitRow,
19
- SyncCoreDb,
20
- } from '@syncular/server/schema';
21
- import type { Kysely, Transaction } from 'kysely';
15
+ import type { DbExecutor } from '@syncular/server';
16
+ import {
17
+ BaseServerSyncDialect,
18
+ coerceIsoString,
19
+ coerceNumber,
20
+ type IncrementalPullRow,
21
+ type IncrementalPullRowsArgs,
22
+ parseScopes,
23
+ } from '@syncular/server';
24
+ import type { SyncChangeRow, SyncCoreDb } from '@syncular/server/schema';
25
+ import type { Kysely, RawBuilder, Transaction } from 'kysely';
22
26
  import { sql } from 'kysely';
23
27
 
24
- function coerceNumber(value: unknown): number | null {
25
- if (value === null || value === undefined) return null;
26
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
27
- if (typeof value === 'bigint')
28
- return Number.isFinite(Number(value)) ? Number(value) : null;
29
- if (typeof value === 'string') {
30
- const n = Number(value);
31
- return Number.isFinite(n) ? n : null;
32
- }
33
- return null;
28
+ function isActiveTransaction<DB extends SyncCoreDb>(
29
+ db: Kysely<DB>
30
+ ): db is Kysely<DB> & Transaction<DB> {
31
+ return (db as { isTransaction?: boolean }).isTransaction === true;
34
32
  }
35
33
 
36
- function coerceIsoString(value: unknown): string {
37
- if (typeof value === 'string') return value;
38
- if (value instanceof Date) return value.toISOString();
39
- return String(value);
34
+ function createSavepointName(): string {
35
+ const randomPart = Math.floor(Math.random() * 1_000_000_000).toString(36);
36
+ return `syncular_sp_${Date.now().toString(36)}_${randomPart}`;
40
37
  }
41
38
 
42
- function parseScopes(value: unknown): StoredScopes {
43
- if (value === null || value === undefined) return {};
44
- if (typeof value === 'object' && !Array.isArray(value)) {
45
- const result: StoredScopes = {};
46
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
47
- if (typeof v === 'string') {
48
- result[k] = v;
49
- }
50
- }
51
- return result;
52
- }
53
- return {};
54
- }
55
-
56
- export class PostgresServerSyncDialect implements ServerSyncDialect {
39
+ export class PostgresServerSyncDialect extends BaseServerSyncDialect {
57
40
  readonly name = 'postgres' as const;
58
41
  readonly supportsForUpdate = true;
59
42
  readonly supportsSavepoints = true;
60
43
 
44
+ // ===========================================================================
45
+ // SQL Fragment Hooks
46
+ // ===========================================================================
47
+
48
+ protected buildNumberListFilter(values: number[]): RawBuilder<unknown> {
49
+ return sql`= ANY(${values}::bigint[])`;
50
+ }
51
+
52
+ protected buildStringListFilter(values: string[]): RawBuilder<unknown> {
53
+ return sql`= ANY(${values}::text[])`;
54
+ }
55
+
61
56
  // ===========================================================================
62
57
  // Schema Setup
63
58
  // ===========================================================================
@@ -67,6 +62,9 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
67
62
  .createTable('sync_commits')
68
63
  .ifNotExists()
69
64
  .addColumn('commit_seq', 'bigserial', (col) => col.primaryKey())
65
+ .addColumn('partition_id', 'text', (col) =>
66
+ col.notNull().defaultTo('default')
67
+ )
70
68
  .addColumn('actor_id', 'text', (col) => col.notNull())
71
69
  .addColumn('client_id', 'text', (col) => col.notNull())
72
70
  .addColumn('client_commit_id', 'text', (col) => col.notNull())
@@ -90,12 +88,17 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
90
88
  ADD COLUMN IF NOT EXISTS affected_tables text[] NOT NULL DEFAULT ARRAY[]::text[]`.execute(
91
89
  db
92
90
  );
91
+ await sql`ALTER TABLE sync_commits
92
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(
93
+ db
94
+ );
93
95
 
96
+ await sql`DROP INDEX IF EXISTS idx_sync_commits_client_commit`.execute(db);
94
97
  await db.schema
95
98
  .createIndex('idx_sync_commits_client_commit')
96
99
  .ifNotExists()
97
100
  .on('sync_commits')
98
- .columns(['client_id', 'client_commit_id'])
101
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
99
102
  .unique()
100
103
  .execute();
101
104
 
@@ -103,18 +106,30 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
103
106
  await db.schema
104
107
  .createTable('sync_table_commits')
105
108
  .ifNotExists()
109
+ .addColumn('partition_id', 'text', (col) =>
110
+ col.notNull().defaultTo('default')
111
+ )
106
112
  .addColumn('table', 'text', (col) => col.notNull())
107
113
  .addColumn('commit_seq', 'bigint', (col) =>
108
114
  col.notNull().references('sync_commits.commit_seq').onDelete('cascade')
109
115
  )
110
- .addPrimaryKeyConstraint('sync_table_commits_pk', ['table', 'commit_seq'])
116
+ .addPrimaryKeyConstraint('sync_table_commits_pk', [
117
+ 'partition_id',
118
+ 'table',
119
+ 'commit_seq',
120
+ ])
111
121
  .execute();
112
122
 
123
+ await sql`ALTER TABLE sync_table_commits
124
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(
125
+ db
126
+ );
127
+
113
128
  await db.schema
114
129
  .createIndex('idx_sync_table_commits_commit_seq')
115
130
  .ifNotExists()
116
131
  .on('sync_table_commits')
117
- .columns(['commit_seq'])
132
+ .columns(['partition_id', 'commit_seq'])
118
133
  .execute();
119
134
 
120
135
  // Changes table with JSONB scopes
@@ -122,6 +137,9 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
122
137
  .createTable('sync_changes')
123
138
  .ifNotExists()
124
139
  .addColumn('change_id', 'bigserial', (col) => col.primaryKey())
140
+ .addColumn('partition_id', 'text', (col) =>
141
+ col.notNull().defaultTo('default')
142
+ )
125
143
  .addColumn('commit_seq', 'bigint', (col) =>
126
144
  col.notNull().references('sync_commits.commit_seq').onDelete('cascade')
127
145
  )
@@ -133,18 +151,23 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
133
151
  .addColumn('scopes', 'jsonb', (col) => col.notNull())
134
152
  .execute();
135
153
 
154
+ await sql`ALTER TABLE sync_changes
155
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(
156
+ db
157
+ );
158
+
136
159
  await db.schema
137
160
  .createIndex('idx_sync_changes_commit_seq')
138
161
  .ifNotExists()
139
162
  .on('sync_changes')
140
- .columns(['commit_seq'])
163
+ .columns(['partition_id', 'commit_seq'])
141
164
  .execute();
142
165
 
143
166
  await db.schema
144
167
  .createIndex('idx_sync_changes_table')
145
168
  .ifNotExists()
146
169
  .on('sync_changes')
147
- .columns(['table'])
170
+ .columns(['partition_id', 'table'])
148
171
  .execute();
149
172
 
150
173
  await this.ensureIndex(
@@ -156,7 +179,10 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
156
179
  await db.schema
157
180
  .createTable('sync_client_cursors')
158
181
  .ifNotExists()
159
- .addColumn('client_id', 'text', (col) => col.primaryKey())
182
+ .addColumn('partition_id', 'text', (col) =>
183
+ col.notNull().defaultTo('default')
184
+ )
185
+ .addColumn('client_id', 'text', (col) => col.notNull())
160
186
  .addColumn('actor_id', 'text', (col) => col.notNull())
161
187
  .addColumn('cursor', 'bigint', (col) => col.notNull().defaultTo(0))
162
188
  .addColumn('effective_scopes', 'jsonb', (col) =>
@@ -165,8 +191,17 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
165
191
  .addColumn('updated_at', 'timestamptz', (col) =>
166
192
  col.notNull().defaultTo(sql`now()`)
167
193
  )
194
+ .addPrimaryKeyConstraint('sync_client_cursors_pk', [
195
+ 'partition_id',
196
+ 'client_id',
197
+ ])
168
198
  .execute();
169
199
 
200
+ await sql`ALTER TABLE sync_client_cursors
201
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(
202
+ db
203
+ );
204
+
170
205
  await db.schema
171
206
  .createIndex('idx_sync_client_cursors_updated_at')
172
207
  .ifNotExists()
@@ -178,6 +213,9 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
178
213
  .createTable('sync_snapshot_chunks')
179
214
  .ifNotExists()
180
215
  .addColumn('chunk_id', 'text', (col) => col.primaryKey())
216
+ .addColumn('partition_id', 'text', (col) =>
217
+ col.notNull().defaultTo('default')
218
+ )
181
219
  .addColumn('scope_key', 'text', (col) => col.notNull())
182
220
  .addColumn('scope', 'text', (col) => col.notNull())
183
221
  .addColumn('as_of_commit_seq', 'bigint', (col) => col.notNull())
@@ -195,6 +233,11 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
195
233
  .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
196
234
  .execute();
197
235
 
236
+ await sql`ALTER TABLE sync_snapshot_chunks
237
+ ADD COLUMN IF NOT EXISTS partition_id text NOT NULL DEFAULT 'default'`.execute(
238
+ db
239
+ );
240
+
198
241
  await db.schema
199
242
  .createIndex('idx_sync_snapshot_chunks_expires_at')
200
243
  .ifNotExists()
@@ -207,6 +250,7 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
207
250
  .ifNotExists()
208
251
  .on('sync_snapshot_chunks')
209
252
  .columns([
253
+ 'partition_id',
210
254
  'scope_key',
211
255
  'scope',
212
256
  'as_of_commit_seq',
@@ -227,6 +271,19 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
227
271
  db: Kysely<DB>,
228
272
  fn: (executor: DbExecutor<DB>) => Promise<T>
229
273
  ): Promise<T> {
274
+ if (isActiveTransaction(db)) {
275
+ const savepoint = createSavepointName();
276
+ await sql.raw(`SAVEPOINT ${savepoint}`).execute(db);
277
+ try {
278
+ const result = await fn(db);
279
+ await sql.raw(`RELEASE SAVEPOINT ${savepoint}`).execute(db);
280
+ return result;
281
+ } catch (error) {
282
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${savepoint}`).execute(db);
283
+ await sql.raw(`RELEASE SAVEPOINT ${savepoint}`).execute(db);
284
+ throw error;
285
+ }
286
+ }
230
287
  return db.transaction().execute(fn);
231
288
  }
232
289
 
@@ -237,42 +294,28 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
237
294
  }
238
295
 
239
296
  // ===========================================================================
240
- // Commit/Change Log Queries
297
+ // Overrides (dialect-specific optimizations / casts)
241
298
  // ===========================================================================
242
299
 
243
- async readMaxCommitSeq<DB extends SyncCoreDb>(
244
- db: Kysely<DB> | Transaction<DB>
245
- ): Promise<number> {
246
- const res = await sql<{ max_seq: unknown }>`
247
- SELECT max(commit_seq) as max_seq
248
- FROM sync_commits
249
- `.execute(db);
250
-
251
- return coerceNumber(res.rows[0]?.max_seq) ?? 0;
252
- }
253
-
254
- async readMinCommitSeq<DB extends SyncCoreDb>(
255
- db: Kysely<DB> | Transaction<DB>
256
- ): Promise<number> {
257
- const res = await sql<{ min_seq: unknown }>`
258
- SELECT min(commit_seq) as min_seq
259
- FROM sync_commits
260
- `.execute(db);
261
-
262
- return coerceNumber(res.rows[0]?.min_seq) ?? 0;
263
- }
264
-
265
300
  async readCommitSeqsForPull<DB extends SyncCoreDb>(
266
301
  db: Kysely<DB> | Transaction<DB>,
267
- args: { cursor: number; limitCommits: number; tables: string[] }
302
+ args: {
303
+ cursor: number;
304
+ limitCommits: number;
305
+ tables: string[];
306
+ partitionId?: string;
307
+ }
268
308
  ): Promise<number[]> {
309
+ const partitionId = args.partitionId ?? 'default';
269
310
  if (args.tables.length === 0) return [];
270
311
 
312
+ // Single-table fast path: skip DISTINCT since (partition_id, table, commit_seq) is PK
271
313
  if (args.tables.length === 1) {
272
314
  const res = await sql<{ commit_seq: unknown }>`
273
315
  SELECT commit_seq
274
316
  FROM sync_table_commits
275
- WHERE "table" = ${args.tables[0]}
317
+ WHERE partition_id = ${partitionId}
318
+ AND "table" = ${args.tables[0]}
276
319
  AND commit_seq > ${args.cursor}
277
320
  ORDER BY commit_seq ASC
278
321
  LIMIT ${args.limitCommits}
@@ -286,61 +329,55 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
286
329
  );
287
330
  }
288
331
 
289
- const res = await sql<{ commit_seq: unknown }>`
290
- SELECT DISTINCT commit_seq
291
- FROM sync_table_commits
292
- WHERE "table" = ANY(${args.tables}::text[])
293
- AND commit_seq > ${args.cursor}
294
- ORDER BY commit_seq ASC
295
- LIMIT ${args.limitCommits}
296
- `.execute(db);
297
-
298
- return res.rows
299
- .map((r) => coerceNumber(r.commit_seq))
300
- .filter(
301
- (n): n is number =>
302
- typeof n === 'number' && Number.isFinite(n) && n > args.cursor
303
- );
332
+ // Multi-table: use ANY() with DISTINCT
333
+ return super.readCommitSeqsForPull(db, args);
304
334
  }
305
335
 
306
- async readCommits<DB extends SyncCoreDb>(
336
+ async recordClientCursor<DB extends SyncCoreDb>(
307
337
  db: Kysely<DB> | Transaction<DB>,
308
- commitSeqs: number[]
309
- ): Promise<SyncCommitRow[]> {
310
- if (commitSeqs.length === 0) return [];
338
+ args: {
339
+ partitionId?: string;
340
+ clientId: string;
341
+ actorId: string;
342
+ cursor: number;
343
+ effectiveScopes: ScopeValues;
344
+ }
345
+ ): Promise<void> {
346
+ const partitionId = args.partitionId ?? 'default';
347
+ const now = new Date().toISOString();
348
+ const scopesJson = JSON.stringify(args.effectiveScopes);
311
349
 
312
- const res = await sql<{
313
- commit_seq: unknown;
314
- actor_id: string;
315
- created_at: unknown;
316
- result_json: unknown | null;
317
- }>`
318
- SELECT commit_seq, actor_id, created_at, result_json
319
- FROM sync_commits
320
- WHERE commit_seq = ANY(${commitSeqs}::bigint[])
321
- ORDER BY commit_seq ASC
350
+ await sql`
351
+ INSERT INTO sync_client_cursors (partition_id, client_id, actor_id, cursor, effective_scopes, updated_at)
352
+ VALUES (${partitionId}, ${args.clientId}, ${args.actorId}, ${args.cursor}, ${scopesJson}::jsonb, ${now})
353
+ ON CONFLICT(partition_id, client_id) DO UPDATE SET
354
+ actor_id = ${args.actorId},
355
+ cursor = ${args.cursor},
356
+ effective_scopes = ${scopesJson}::jsonb,
357
+ updated_at = ${now}
322
358
  `.execute(db);
323
-
324
- return res.rows.map((row) => ({
325
- commit_seq: coerceNumber(row.commit_seq) ?? 0,
326
- actor_id: row.actor_id,
327
- created_at: coerceIsoString(row.created_at),
328
- result_json: row.result_json ?? null,
329
- }));
330
359
  }
331
360
 
361
+ // ===========================================================================
362
+ // Commit/Change Log Queries (dialect-specific)
363
+ // ===========================================================================
364
+
332
365
  async readChangesForCommits<DB extends SyncCoreDb>(
333
- db: Kysely<DB> | Transaction<DB>,
334
- args: { commitSeqs: number[]; table: string; scopes: ScopeValues }
366
+ db: DbExecutor<DB>,
367
+ args: {
368
+ commitSeqs: number[];
369
+ table: string;
370
+ scopes: ScopeValues;
371
+ partitionId?: string;
372
+ }
335
373
  ): Promise<SyncChangeRow[]> {
374
+ const partitionId = args.partitionId ?? 'default';
336
375
  if (args.commitSeqs.length === 0) return [];
337
376
 
338
377
  // Build JSONB containment conditions for scope filtering
339
- // For each scope key/value, we need: scopes->>'key' = 'value' OR scopes->>'key' IN (values)
340
378
  const scopeConditions: ReturnType<typeof sql>[] = [];
341
379
  for (const [key, value] of Object.entries(args.scopes)) {
342
380
  if (Array.isArray(value)) {
343
- // OR condition for array values
344
381
  scopeConditions.push(sql`scopes->>${key} = ANY(${value}::text[])`);
345
382
  } else {
346
383
  scopeConditions.push(sql`scopes->>${key} = ${value}`);
@@ -359,6 +396,7 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
359
396
  SELECT commit_seq, "table", row_id, op, row_json, row_version, scopes
360
397
  FROM sync_changes
361
398
  WHERE commit_seq = ANY(${args.commitSeqs}::bigint[])
399
+ AND partition_id = ${partitionId}
362
400
  AND "table" = ${args.table}
363
401
  `;
364
402
 
@@ -376,6 +414,7 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
376
414
  SELECT commit_seq, "table", row_id, op, row_json, row_version, scopes
377
415
  FROM sync_changes
378
416
  WHERE commit_seq = ANY(${args.commitSeqs}::bigint[])
417
+ AND partition_id = ${partitionId}
379
418
  AND "table" = ${args.table}
380
419
  AND (${scopeFilter})
381
420
  ORDER BY commit_seq ASC, change_id ASC
@@ -395,28 +434,11 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
395
434
  }));
396
435
  }
397
436
 
398
- async readIncrementalPullRows<DB extends SyncCoreDb>(
399
- db: Kysely<DB> | Transaction<DB>,
400
- args: {
401
- table: string;
402
- scopes: ScopeValues;
403
- cursor: number;
404
- limitCommits: number;
405
- }
406
- ): Promise<
407
- Array<{
408
- commit_seq: number;
409
- actor_id: string;
410
- created_at: string;
411
- change_id: number;
412
- table: string;
413
- row_id: string;
414
- op: SyncOp;
415
- row_json: unknown | null;
416
- row_version: number | null;
417
- scopes: StoredScopes;
418
- }>
419
- > {
437
+ protected override async readIncrementalPullRowsBatch<DB extends SyncCoreDb>(
438
+ db: DbExecutor<DB>,
439
+ args: Omit<IncrementalPullRowsArgs, 'batchSize'>
440
+ ): Promise<IncrementalPullRow[]> {
441
+ const partitionId = args.partitionId ?? 'default';
420
442
  const limitCommits = Math.max(1, Math.min(500, args.limitCommits));
421
443
 
422
444
  // Build scope filter conditions
@@ -450,12 +472,15 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
450
472
  SELECT DISTINCT tc.commit_seq
451
473
  FROM sync_table_commits tc
452
474
  JOIN sync_commits cm ON cm.commit_seq = tc.commit_seq
453
- WHERE tc."table" = ${args.table}
475
+ WHERE tc.partition_id = ${partitionId}
476
+ AND tc."table" = ${args.table}
477
+ AND cm.partition_id = ${partitionId}
454
478
  AND tc.commit_seq > ${args.cursor}
455
479
  AND EXISTS (
456
480
  SELECT 1
457
481
  FROM sync_changes c
458
482
  WHERE c.commit_seq = tc.commit_seq
483
+ AND c.partition_id = ${partitionId}
459
484
  AND c."table" = ${args.table}
460
485
  AND (${scopeFilter})
461
486
  )
@@ -476,7 +501,9 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
476
501
  FROM commit_seqs cs
477
502
  JOIN sync_commits cm ON cm.commit_seq = cs.commit_seq
478
503
  JOIN sync_changes c ON c.commit_seq = cs.commit_seq
479
- WHERE c."table" = ${args.table}
504
+ WHERE cm.partition_id = ${partitionId}
505
+ AND c.partition_id = ${partitionId}
506
+ AND c."table" = ${args.table}
480
507
  AND (${scopeFilter})
481
508
  ORDER BY cm.commit_seq ASC, c.change_id ASC
482
509
  `.execute(db);
@@ -495,51 +522,8 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
495
522
  }));
496
523
  }
497
524
 
498
- async *streamIncrementalPullRows<DB extends SyncCoreDb>(
499
- db: Kysely<DB> | Transaction<DB>,
500
- args: {
501
- table: string;
502
- scopes: ScopeValues;
503
- cursor: number;
504
- limitCommits: number;
505
- batchSize?: number;
506
- }
507
- ): AsyncGenerator<{
508
- commit_seq: number;
509
- actor_id: string;
510
- created_at: string;
511
- change_id: number;
512
- table: string;
513
- row_id: string;
514
- op: SyncOp;
515
- row_json: unknown | null;
516
- row_version: number | null;
517
- scopes: StoredScopes;
518
- }> {
519
- // PostgreSQL: use batching approach (could use pg-query-stream for true streaming)
520
- const batchSize = Math.min(100, args.batchSize ?? 100);
521
- let processed = 0;
522
-
523
- while (processed < args.limitCommits) {
524
- const batch = await this.readIncrementalPullRows(db, {
525
- ...args,
526
- limitCommits: Math.min(batchSize, args.limitCommits - processed),
527
- cursor: args.cursor + processed,
528
- });
529
-
530
- if (batch.length === 0) break;
531
-
532
- for (const row of batch) {
533
- yield row;
534
- }
535
-
536
- processed += batch.length;
537
- if (batch.length < batchSize) break;
538
- }
539
- }
540
-
541
525
  async compactChanges<DB extends SyncCoreDb>(
542
- db: Kysely<DB> | Transaction<DB>,
526
+ db: DbExecutor<DB>,
543
527
  args: { fullHistoryHours: number }
544
528
  ): Promise<number> {
545
529
  const cutoffIso = new Date(
@@ -551,7 +535,7 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
551
535
  SELECT
552
536
  c.change_id,
553
537
  row_number() OVER (
554
- PARTITION BY c."table", c.row_id, c.scopes
538
+ PARTITION BY c.partition_id, c."table", c.row_id, c.scopes
555
539
  ORDER BY c.commit_seq DESC, c.change_id DESC
556
540
  ) AS rn
557
541
  FROM sync_changes c
@@ -568,12 +552,14 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
568
552
  await sql`
569
553
  DELETE FROM sync_table_commits tc
570
554
  USING sync_commits cm
571
- WHERE cm.commit_seq = tc.commit_seq
572
- AND cm.created_at < ${cutoffIso}
555
+ WHERE cm.commit_seq = tc.commit_seq
556
+ AND cm.partition_id = tc.partition_id
557
+ AND cm.created_at < ${cutoffIso}
573
558
  AND NOT EXISTS (
574
559
  SELECT 1
575
560
  FROM sync_changes c
576
561
  WHERE c.commit_seq = tc.commit_seq
562
+ AND c.partition_id = tc.partition_id
577
563
  AND c."table" = tc."table"
578
564
  )
579
565
  `.execute(db);
@@ -581,33 +567,6 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
581
567
  return deletedChanges;
582
568
  }
583
569
 
584
- // ===========================================================================
585
- // Client Cursor Recording
586
- // ===========================================================================
587
-
588
- async recordClientCursor<DB extends SyncCoreDb>(
589
- db: Kysely<DB> | Transaction<DB>,
590
- args: {
591
- clientId: string;
592
- actorId: string;
593
- cursor: number;
594
- effectiveScopes: ScopeValues;
595
- }
596
- ): Promise<void> {
597
- const now = new Date().toISOString();
598
- const scopesJson = JSON.stringify(args.effectiveScopes);
599
-
600
- await sql`
601
- INSERT INTO sync_client_cursors (client_id, actor_id, cursor, effective_scopes, updated_at)
602
- VALUES (${args.clientId}, ${args.actorId}, ${args.cursor}, ${scopesJson}::jsonb, ${now})
603
- ON CONFLICT(client_id) DO UPDATE SET
604
- actor_id = ${args.actorId},
605
- cursor = ${args.cursor},
606
- effective_scopes = ${scopesJson}::jsonb,
607
- updated_at = ${now}
608
- `.execute(db);
609
- }
610
-
611
570
  // ===========================================================================
612
571
  // Scope Conversion Helpers
613
572
  // ===========================================================================
@@ -616,10 +575,6 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
616
575
  return scopes;
617
576
  }
618
577
 
619
- dbToScopes(value: unknown): StoredScopes {
620
- return parseScopes(value);
621
- }
622
-
623
578
  dbToArray(value: unknown): string[] {
624
579
  if (Array.isArray(value)) {
625
580
  return value.filter((k: unknown): k is string => typeof k === 'string');
@@ -631,21 +586,6 @@ export class PostgresServerSyncDialect implements ServerSyncDialect {
631
586
  return values.filter((v) => v.length > 0);
632
587
  }
633
588
 
634
- async readAffectedTablesFromChanges<DB extends SyncCoreDb>(
635
- db: Kysely<DB> | Transaction<DB>,
636
- commitSeq: number
637
- ): Promise<string[]> {
638
- const res = await sql<{ table: string }>`
639
- SELECT DISTINCT "table"
640
- FROM sync_changes
641
- WHERE commit_seq = ${commitSeq}
642
- `.execute(db);
643
-
644
- return res.rows
645
- .map((r) => r.table)
646
- .filter((t): t is string => typeof t === 'string' && t.length > 0);
647
- }
648
-
649
589
  // ===========================================================================
650
590
  // Console Schema (Request Events)
651
591
  // ===========================================================================