@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/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
|
|
14
|
-
import type { DbExecutor
|
|
15
|
-
import
|
|
16
|
-
import type {
|
|
17
|
-
|
|
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
|
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,
|
|
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
|
|
15
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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', [
|
|
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('
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
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
|
// ===========================================================================
|