@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 +33 -0
- package/dist/index.d.ts +20 -53
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -137
- package/dist/index.js.map +1 -1
- package/package.json +25 -5
- package/src/index.test.ts +170 -0
- package/src/index.ts +164 -224
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
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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', [
|
|
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('
|
|
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
|
-
//
|
|
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: {
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
336
|
+
async recordClientCursor<DB extends SyncCoreDb>(
|
|
307
337
|
db: Kysely<DB> | Transaction<DB>,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
334
|
-
args: {
|
|
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
|
|
399
|
-
db:
|
|
400
|
-
args:
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
572
|
-
|
|
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
|
// ===========================================================================
|