@syncular/client 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 +23 -0
- package/dist/blobs/index.js +3 -3
- package/dist/client.d.ts +10 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -21
- package/dist/client.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -7
- package/dist/conflicts.js.map +1 -1
- package/dist/create-client.d.ts +5 -1
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +22 -10
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +24 -2
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +290 -43
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/types.d.ts +16 -4
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/create-handler.d.ts +15 -5
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +35 -24
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -5
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.js +19 -19
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +148 -28
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts +3 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +93 -18
- package/dist/mutations.js.map +1 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +1 -11
- package/dist/outbox.js.map +1 -1
- package/dist/plugins/incrementing-version.d.ts +1 -1
- package/dist/plugins/incrementing-version.js +2 -2
- package/dist/plugins/index.js +2 -2
- package/dist/proxy/dialect.js +1 -1
- package/dist/proxy/driver.js +1 -1
- package/dist/proxy/index.js +4 -4
- package/dist/proxy/mutations.js +1 -1
- package/dist/pull-engine.d.ts +29 -3
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +314 -78
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +28 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.d.ts +2 -1
- package/dist/query/tracked-select.d.ts.map +1 -1
- package/dist/query/tracked-select.js +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/sync-loop.d.ts +5 -1
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +167 -18
- package/dist/sync-loop.js.map +1 -1
- package/package.json +30 -6
- package/src/client.test.ts +369 -0
- package/src/client.ts +101 -22
- package/src/conflicts.ts +1 -10
- package/src/create-client.ts +33 -5
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +359 -40
- package/src/engine/types.ts +22 -4
- package/src/handlers/create-handler.ts +86 -37
- package/src/handlers/types.ts +10 -4
- package/src/migrate.ts +215 -33
- package/src/mutations.ts +143 -21
- package/src/outbox.ts +1 -15
- package/src/plugins/incrementing-version.ts +2 -2
- package/src/pull-engine.test.ts +147 -0
- package/src/pull-engine.ts +392 -77
- package/src/push-engine.ts +33 -1
- package/src/query/tracked-select.ts +1 -1
- package/src/schema.ts +2 -2
- package/src/sync-loop.ts +215 -19
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
* @syncular/client - Declarative client handler helper
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
|
|
5
|
+
import type {
|
|
6
|
+
ColumnCodecDialect,
|
|
7
|
+
ColumnCodecSource,
|
|
8
|
+
ScopeDefinition,
|
|
9
|
+
ScopeKeysFromDefinitions,
|
|
10
|
+
ScopeValuesFromPatterns,
|
|
11
|
+
SyncChange,
|
|
12
|
+
SyncSnapshot,
|
|
13
|
+
} from '@syncular/core';
|
|
14
|
+
import {
|
|
15
|
+
applyCodecsToDbRow,
|
|
16
|
+
isRecord,
|
|
17
|
+
normalizeScopes,
|
|
18
|
+
toTableColumnCodecs,
|
|
19
|
+
} from '@syncular/core';
|
|
7
20
|
import { sql } from 'kysely';
|
|
8
21
|
import type { SyncClientDb } from '../schema';
|
|
9
22
|
import type {
|
|
@@ -13,20 +26,7 @@ import type {
|
|
|
13
26
|
ClientTableHandler,
|
|
14
27
|
} from './types';
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Coerce a value for SQL parameter binding.
|
|
22
|
-
* PostgreSQL (PGlite) does not implicitly cast booleans to integers,
|
|
23
|
-
* so we convert them to 0/1 before binding.
|
|
24
|
-
*/
|
|
25
|
-
function coerceForSql(value: unknown): unknown {
|
|
26
|
-
if (value === undefined) return null;
|
|
27
|
-
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
28
|
-
return value;
|
|
29
|
-
}
|
|
29
|
+
const MAX_INSERT_BIND_PARAMETERS = 900;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Options for creating a declarative client handler.
|
|
@@ -34,6 +34,7 @@ function coerceForSql(value: unknown): unknown {
|
|
|
34
34
|
export interface CreateClientHandlerOptions<
|
|
35
35
|
DB extends SyncClientDb,
|
|
36
36
|
TableName extends keyof DB & string,
|
|
37
|
+
ScopeDefs extends readonly ScopeDefinition[] = readonly ScopeDefinition[],
|
|
37
38
|
> {
|
|
38
39
|
/** Table name in the database */
|
|
39
40
|
table: TableName;
|
|
@@ -53,7 +54,7 @@ export interface CreateClientHandlerOptions<
|
|
|
53
54
|
* ]
|
|
54
55
|
* ```
|
|
55
56
|
*/
|
|
56
|
-
scopes:
|
|
57
|
+
scopes: ScopeDefs;
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* Subscription configuration for this table.
|
|
@@ -64,7 +65,7 @@ export interface CreateClientHandlerOptions<
|
|
|
64
65
|
subscribe?:
|
|
65
66
|
| boolean
|
|
66
67
|
| {
|
|
67
|
-
scopes?:
|
|
68
|
+
scopes?: ScopeValuesFromPatterns<ScopeDefs>;
|
|
68
69
|
params?: Record<string, unknown>;
|
|
69
70
|
};
|
|
70
71
|
|
|
@@ -77,6 +78,18 @@ export interface CreateClientHandlerOptions<
|
|
|
77
78
|
*/
|
|
78
79
|
versionColumn?: keyof DB[TableName] & string;
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Optional column codec resolver.
|
|
83
|
+
* Receives `{ table, column, sqlType?, dialect? }` and returns a codec.
|
|
84
|
+
*/
|
|
85
|
+
columnCodecs?: ColumnCodecSource;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dialect used for codec dialect overrides.
|
|
89
|
+
* Default: 'sqlite'
|
|
90
|
+
*/
|
|
91
|
+
codecDialect?: ColumnCodecDialect;
|
|
92
|
+
|
|
80
93
|
/**
|
|
81
94
|
* Override: Apply a snapshot.
|
|
82
95
|
* Default: upsert all rows (no delete on isFirstPage).
|
|
@@ -154,13 +167,30 @@ export interface CreateClientHandlerOptions<
|
|
|
154
167
|
export function createClientHandler<
|
|
155
168
|
DB extends SyncClientDb,
|
|
156
169
|
TableName extends keyof DB & string,
|
|
170
|
+
ScopeDefs extends readonly ScopeDefinition[] = readonly ScopeDefinition[],
|
|
157
171
|
>(
|
|
158
|
-
options: CreateClientHandlerOptions<DB, TableName>
|
|
159
|
-
): ClientTableHandler<DB, TableName
|
|
172
|
+
options: CreateClientHandlerOptions<DB, TableName, ScopeDefs>
|
|
173
|
+
): ClientTableHandler<DB, TableName, ScopeKeysFromDefinitions<ScopeDefs>> {
|
|
160
174
|
const { table, scopes: scopeDefs } = options;
|
|
161
175
|
const primaryKey =
|
|
162
176
|
options.primaryKey ?? ('id' as keyof DB[TableName] & string);
|
|
163
177
|
const versionColumn = options.versionColumn;
|
|
178
|
+
const codecDialect = options.codecDialect ?? 'sqlite';
|
|
179
|
+
const codecCache = new Map<string, ReturnType<typeof toTableColumnCodecs>>();
|
|
180
|
+
const resolveTableCodecs = (row: Record<string, unknown>) => {
|
|
181
|
+
const columnCodecs = options.columnCodecs;
|
|
182
|
+
if (!columnCodecs) return {};
|
|
183
|
+
const columns = Object.keys(row);
|
|
184
|
+
if (columns.length === 0) return {};
|
|
185
|
+
const cacheKey = columns.slice().sort().join('\u0000');
|
|
186
|
+
const cached = codecCache.get(cacheKey);
|
|
187
|
+
if (cached) return cached;
|
|
188
|
+
const resolved = toTableColumnCodecs(table, columnCodecs, columns, {
|
|
189
|
+
dialect: codecDialect,
|
|
190
|
+
});
|
|
191
|
+
codecCache.set(cacheKey, resolved);
|
|
192
|
+
return resolved;
|
|
193
|
+
};
|
|
164
194
|
|
|
165
195
|
// Normalize scopes to pattern map (stored for metadata)
|
|
166
196
|
const scopeColumnMap = normalizeScopes(scopeDefs);
|
|
@@ -174,7 +204,7 @@ export function createClientHandler<
|
|
|
174
204
|
const rows: Array<Record<string, unknown>> = [];
|
|
175
205
|
for (const row of snapshot.rows ?? []) {
|
|
176
206
|
if (!isRecord(row)) continue;
|
|
177
|
-
rows.push(row);
|
|
207
|
+
rows.push(applyCodecsToDbRow(row, resolveTableCodecs(row), codecDialect));
|
|
178
208
|
}
|
|
179
209
|
|
|
180
210
|
if (rows.length === 0) return;
|
|
@@ -194,20 +224,33 @@ export function createClientHandler<
|
|
|
194
224
|
sql`, `
|
|
195
225
|
)}`;
|
|
196
226
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
227
|
+
const maxRowsPerInsert = Math.max(
|
|
228
|
+
1,
|
|
229
|
+
Math.floor(MAX_INSERT_BIND_PARAMETERS / columns.length)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
for (
|
|
233
|
+
let startIndex = 0;
|
|
234
|
+
startIndex < rows.length;
|
|
235
|
+
startIndex += maxRowsPerInsert
|
|
236
|
+
) {
|
|
237
|
+
const batchRows = rows.slice(startIndex, startIndex + maxRowsPerInsert);
|
|
238
|
+
|
|
239
|
+
await sql`
|
|
240
|
+
insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
|
|
241
|
+
values ${sql.join(
|
|
242
|
+
batchRows.map(
|
|
243
|
+
(row) =>
|
|
244
|
+
sql`(${sql.join(
|
|
245
|
+
columns.map((col) => sql.val(row[col] ?? null)),
|
|
246
|
+
sql`, `
|
|
247
|
+
)})`
|
|
248
|
+
),
|
|
249
|
+
sql`, `
|
|
250
|
+
)}
|
|
251
|
+
on conflict (${sql.ref(primaryKey)}) ${onConflict}
|
|
252
|
+
`.execute(ctx.trx);
|
|
253
|
+
}
|
|
211
254
|
};
|
|
212
255
|
|
|
213
256
|
// Default applyChange: upsert on upsert, delete on delete
|
|
@@ -223,7 +266,13 @@ export function createClientHandler<
|
|
|
223
266
|
return;
|
|
224
267
|
}
|
|
225
268
|
|
|
226
|
-
const row = isRecord(change.row_json)
|
|
269
|
+
const row = isRecord(change.row_json)
|
|
270
|
+
? applyCodecsToDbRow(
|
|
271
|
+
change.row_json,
|
|
272
|
+
resolveTableCodecs(change.row_json),
|
|
273
|
+
codecDialect
|
|
274
|
+
)
|
|
275
|
+
: {};
|
|
227
276
|
const insertRow: Record<string, unknown> = {
|
|
228
277
|
...row,
|
|
229
278
|
[primaryKey]: change.row_id,
|
|
@@ -252,7 +301,7 @@ export function createClientHandler<
|
|
|
252
301
|
await sql`
|
|
253
302
|
insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
|
|
254
303
|
values (${sql.join(
|
|
255
|
-
columns.map((col) => sql.val(
|
|
304
|
+
columns.map((col) => sql.val(insertRow[col] ?? null)),
|
|
256
305
|
sql`, `
|
|
257
306
|
)})
|
|
258
307
|
on conflict (${sql.ref(primaryKey)}) ${onConflict}
|
package/src/handlers/types.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* @syncular/client - Sync client table handler interface
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ScopeValues,
|
|
7
|
+
ScopeValuesForKeys,
|
|
8
|
+
SyncChange,
|
|
9
|
+
SyncSnapshot,
|
|
10
|
+
} from '@syncular/core';
|
|
6
11
|
import type { Transaction } from 'kysely';
|
|
7
12
|
|
|
8
13
|
/**
|
|
@@ -35,9 +40,9 @@ export interface ClientClearContext<DB> extends ClientHandlerContext<DB> {
|
|
|
35
40
|
/**
|
|
36
41
|
* Subscription configuration for a handler.
|
|
37
42
|
*/
|
|
38
|
-
export interface HandlerSubscriptionConfig {
|
|
43
|
+
export interface HandlerSubscriptionConfig<ScopeKeys extends string = string> {
|
|
39
44
|
/** Scope values for this subscription */
|
|
40
|
-
scopes?:
|
|
45
|
+
scopes?: ScopeValuesForKeys<ScopeKeys>;
|
|
41
46
|
/** Params for this subscription */
|
|
42
47
|
params?: Record<string, unknown>;
|
|
43
48
|
}
|
|
@@ -48,6 +53,7 @@ export interface HandlerSubscriptionConfig {
|
|
|
48
53
|
export interface ClientTableHandler<
|
|
49
54
|
DB,
|
|
50
55
|
TableName extends keyof DB & string = keyof DB & string,
|
|
56
|
+
ScopeKeys extends string = string,
|
|
51
57
|
> {
|
|
52
58
|
/** Table name (used as identifier in sync operations) */
|
|
53
59
|
table: TableName;
|
|
@@ -64,7 +70,7 @@ export interface ClientTableHandler<
|
|
|
64
70
|
* - `false`: Don't subscribe (local-only handler)
|
|
65
71
|
* - Object: Subscribe with custom scopes/params
|
|
66
72
|
*/
|
|
67
|
-
subscribe?: boolean | HandlerSubscriptionConfig
|
|
73
|
+
subscribe?: boolean | HandlerSubscriptionConfig<ScopeKeys>;
|
|
68
74
|
|
|
69
75
|
/**
|
|
70
76
|
* Apply a snapshot page for this table.
|
package/src/migrate.ts
CHANGED
|
@@ -2,9 +2,188 @@
|
|
|
2
2
|
* @syncular/client - Sync migrations (SQLite reference)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import { type Kysely, sql } from 'kysely';
|
|
6
6
|
import type { SyncClientDb } from './schema';
|
|
7
7
|
|
|
8
|
+
type SyncInternalTable =
|
|
9
|
+
| 'sync_subscription_state'
|
|
10
|
+
| 'sync_outbox_commits'
|
|
11
|
+
| 'sync_conflicts';
|
|
12
|
+
|
|
13
|
+
function toErrorMessage(error: unknown): string {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isMissingTableError(message: string): boolean {
|
|
18
|
+
const normalized = message.toLowerCase();
|
|
19
|
+
return (
|
|
20
|
+
normalized.includes('no such table') ||
|
|
21
|
+
(normalized.includes('relation') && normalized.includes('does not exist'))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isMissingColumnError(message: string): boolean {
|
|
26
|
+
const normalized = message.toLowerCase();
|
|
27
|
+
return (
|
|
28
|
+
normalized.includes('no such column') ||
|
|
29
|
+
(normalized.includes('column') && normalized.includes('does not exist'))
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDuplicateColumnError(message: string): boolean {
|
|
34
|
+
const normalized = message.toLowerCase();
|
|
35
|
+
return (
|
|
36
|
+
normalized.includes('duplicate column name') ||
|
|
37
|
+
(normalized.includes('column') && normalized.includes('already exists'))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getColumnNames<DB extends SyncClientDb>(
|
|
42
|
+
db: Kysely<DB>,
|
|
43
|
+
tableName: SyncInternalTable
|
|
44
|
+
): Promise<Set<string> | null> {
|
|
45
|
+
try {
|
|
46
|
+
const sqlite = await sql<{ name: string }>`
|
|
47
|
+
select name from pragma_table_info(${sql.val(tableName)})
|
|
48
|
+
`.execute(db);
|
|
49
|
+
return new Set(sqlite.rows.map((row) => String(row.name)));
|
|
50
|
+
} catch {
|
|
51
|
+
// Not SQLite or pragma unavailable.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const postgres = await sql<{ name: string }>`
|
|
56
|
+
select column_name as name
|
|
57
|
+
from information_schema.columns
|
|
58
|
+
where table_name = ${sql.val(tableName)}
|
|
59
|
+
`.execute(db);
|
|
60
|
+
return new Set(postgres.rows.map((row) => String(row.name)));
|
|
61
|
+
} catch {
|
|
62
|
+
// Introspection unavailable; caller falls back to probing.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function hasColumn<DB extends SyncClientDb>(
|
|
69
|
+
db: Kysely<DB>,
|
|
70
|
+
tableName: SyncInternalTable,
|
|
71
|
+
columnName: string
|
|
72
|
+
): Promise<boolean> {
|
|
73
|
+
const columns = await getColumnNames(db, tableName);
|
|
74
|
+
if (columns) {
|
|
75
|
+
return columns.has(columnName);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await sql`select ${sql.ref(columnName)} from ${sql.table(tableName)} limit 1`.execute(
|
|
80
|
+
db
|
|
81
|
+
);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = toErrorMessage(error);
|
|
85
|
+
if (isMissingTableError(message) || isMissingColumnError(message)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function addColumnIfMissing<DB extends SyncClientDb>(
|
|
93
|
+
db: Kysely<DB>,
|
|
94
|
+
tableName: SyncInternalTable,
|
|
95
|
+
columnName: string,
|
|
96
|
+
addColumn: () => Promise<void>
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (await hasColumn(db, tableName, columnName)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await addColumn();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const message = toErrorMessage(error);
|
|
105
|
+
if (isDuplicateColumnError(message)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function ensureClientSyncSchemaCompat<DB extends SyncClientDb>(
|
|
113
|
+
db: Kysely<DB>
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const hasTableColumn = await hasColumn(
|
|
116
|
+
db,
|
|
117
|
+
'sync_subscription_state',
|
|
118
|
+
'table'
|
|
119
|
+
);
|
|
120
|
+
if (
|
|
121
|
+
!hasTableColumn &&
|
|
122
|
+
(await hasColumn(db, 'sync_subscription_state', 'shape'))
|
|
123
|
+
) {
|
|
124
|
+
try {
|
|
125
|
+
await sql`alter table ${sql.table('sync_subscription_state')} rename column ${sql.ref('shape')} to ${sql.ref('table')}`.execute(
|
|
126
|
+
db
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
await addColumnIfMissing(
|
|
130
|
+
db,
|
|
131
|
+
'sync_subscription_state',
|
|
132
|
+
'table',
|
|
133
|
+
async () => {
|
|
134
|
+
await db.schema
|
|
135
|
+
.alterTable('sync_subscription_state')
|
|
136
|
+
.addColumn('table', 'text', (col) => col.notNull().defaultTo(''))
|
|
137
|
+
.execute();
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
await sql`update ${sql.table('sync_subscription_state')}
|
|
141
|
+
set ${sql.ref('table')} = ${sql.ref('shape')}
|
|
142
|
+
where ${sql.ref('table')} = ${sql.val('')}`.execute(db);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await addColumnIfMissing(
|
|
147
|
+
db,
|
|
148
|
+
'sync_subscription_state',
|
|
149
|
+
'bootstrap_state_json',
|
|
150
|
+
async () => {
|
|
151
|
+
await db.schema
|
|
152
|
+
.alterTable('sync_subscription_state')
|
|
153
|
+
.addColumn('bootstrap_state_json', 'text')
|
|
154
|
+
.execute();
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await addColumnIfMissing(
|
|
159
|
+
db,
|
|
160
|
+
'sync_outbox_commits',
|
|
161
|
+
'schema_version',
|
|
162
|
+
async () => {
|
|
163
|
+
await db.schema
|
|
164
|
+
.alterTable('sync_outbox_commits')
|
|
165
|
+
.addColumn('schema_version', 'integer', (col) =>
|
|
166
|
+
col.notNull().defaultTo(1)
|
|
167
|
+
)
|
|
168
|
+
.execute();
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await addColumnIfMissing(db, 'sync_conflicts', 'resolved_at', async () => {
|
|
173
|
+
await db.schema
|
|
174
|
+
.alterTable('sync_conflicts')
|
|
175
|
+
.addColumn('resolved_at', 'bigint')
|
|
176
|
+
.execute();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await addColumnIfMissing(db, 'sync_conflicts', 'resolution', async () => {
|
|
180
|
+
await db.schema
|
|
181
|
+
.alterTable('sync_conflicts')
|
|
182
|
+
.addColumn('resolution', 'text')
|
|
183
|
+
.execute();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
8
187
|
/**
|
|
9
188
|
* Ensures the client sync schema exists in the database.
|
|
10
189
|
* Safe to call multiple times (idempotent).
|
|
@@ -14,13 +193,13 @@ import type { SyncClientDb } from './schema';
|
|
|
14
193
|
export async function ensureClientSyncSchema<DB extends SyncClientDb>(
|
|
15
194
|
db: Kysely<DB>
|
|
16
195
|
): Promise<void> {
|
|
17
|
-
// Schema builder doesn't need typed access - operates on raw SQL
|
|
196
|
+
// Schema builder doesn't need typed access - operates on raw SQL.
|
|
18
197
|
await db.schema
|
|
19
198
|
.createTable('sync_subscription_state')
|
|
20
199
|
.ifNotExists()
|
|
21
200
|
.addColumn('state_id', 'text', (col) => col.notNull())
|
|
22
201
|
.addColumn('subscription_id', 'text', (col) => col.notNull())
|
|
23
|
-
.addColumn('
|
|
202
|
+
.addColumn('table', 'text', (col) => col.notNull())
|
|
24
203
|
.addColumn('scopes_json', 'text', (col) => col.notNull().defaultTo('{}'))
|
|
25
204
|
.addColumn('params_json', 'text', (col) => col.notNull())
|
|
26
205
|
.addColumn('cursor', 'bigint', (col) => col.notNull())
|
|
@@ -30,21 +209,6 @@ export async function ensureClientSyncSchema<DB extends SyncClientDb>(
|
|
|
30
209
|
.addColumn('updated_at', 'bigint', (col) => col.notNull())
|
|
31
210
|
.execute();
|
|
32
211
|
|
|
33
|
-
await db.schema
|
|
34
|
-
.createIndex('idx_sync_subscription_state_state_sub')
|
|
35
|
-
.ifNotExists()
|
|
36
|
-
.on('sync_subscription_state')
|
|
37
|
-
.columns(['state_id', 'subscription_id'])
|
|
38
|
-
.unique()
|
|
39
|
-
.execute();
|
|
40
|
-
|
|
41
|
-
await db.schema
|
|
42
|
-
.createIndex('idx_sync_subscription_state_state')
|
|
43
|
-
.ifNotExists()
|
|
44
|
-
.on('sync_subscription_state')
|
|
45
|
-
.columns(['state_id', 'updated_at'])
|
|
46
|
-
.execute();
|
|
47
|
-
|
|
48
212
|
await db.schema
|
|
49
213
|
.createTable('sync_outbox_commits')
|
|
50
214
|
.ifNotExists()
|
|
@@ -61,21 +225,6 @@ export async function ensureClientSyncSchema<DB extends SyncClientDb>(
|
|
|
61
225
|
.addColumn('schema_version', 'integer', (col) => col.notNull().defaultTo(1))
|
|
62
226
|
.execute();
|
|
63
227
|
|
|
64
|
-
await db.schema
|
|
65
|
-
.createIndex('idx_sync_outbox_commits_client_commit_id')
|
|
66
|
-
.ifNotExists()
|
|
67
|
-
.on('sync_outbox_commits')
|
|
68
|
-
.columns(['client_commit_id'])
|
|
69
|
-
.unique()
|
|
70
|
-
.execute();
|
|
71
|
-
|
|
72
|
-
await db.schema
|
|
73
|
-
.createIndex('idx_sync_outbox_commits_status_created_at')
|
|
74
|
-
.ifNotExists()
|
|
75
|
-
.on('sync_outbox_commits')
|
|
76
|
-
.columns(['status', 'created_at'])
|
|
77
|
-
.execute();
|
|
78
|
-
|
|
79
228
|
await db.schema
|
|
80
229
|
.createTable('sync_conflicts')
|
|
81
230
|
.ifNotExists()
|
|
@@ -93,6 +242,39 @@ export async function ensureClientSyncSchema<DB extends SyncClientDb>(
|
|
|
93
242
|
.addColumn('resolution', 'text')
|
|
94
243
|
.execute();
|
|
95
244
|
|
|
245
|
+
// Apply framework-managed compatibility upgrades for legacy sync tables.
|
|
246
|
+
await ensureClientSyncSchemaCompat(db);
|
|
247
|
+
|
|
248
|
+
await db.schema
|
|
249
|
+
.createIndex('idx_sync_subscription_state_state_sub')
|
|
250
|
+
.ifNotExists()
|
|
251
|
+
.on('sync_subscription_state')
|
|
252
|
+
.columns(['state_id', 'subscription_id'])
|
|
253
|
+
.unique()
|
|
254
|
+
.execute();
|
|
255
|
+
|
|
256
|
+
await db.schema
|
|
257
|
+
.createIndex('idx_sync_subscription_state_state')
|
|
258
|
+
.ifNotExists()
|
|
259
|
+
.on('sync_subscription_state')
|
|
260
|
+
.columns(['state_id', 'updated_at'])
|
|
261
|
+
.execute();
|
|
262
|
+
|
|
263
|
+
await db.schema
|
|
264
|
+
.createIndex('idx_sync_outbox_commits_client_commit_id')
|
|
265
|
+
.ifNotExists()
|
|
266
|
+
.on('sync_outbox_commits')
|
|
267
|
+
.columns(['client_commit_id'])
|
|
268
|
+
.unique()
|
|
269
|
+
.execute();
|
|
270
|
+
|
|
271
|
+
await db.schema
|
|
272
|
+
.createIndex('idx_sync_outbox_commits_status_created_at')
|
|
273
|
+
.ifNotExists()
|
|
274
|
+
.on('sync_outbox_commits')
|
|
275
|
+
.columns(['status', 'created_at'])
|
|
276
|
+
.execute();
|
|
277
|
+
|
|
96
278
|
await db.schema
|
|
97
279
|
.createIndex('idx_sync_conflicts_outbox_commit')
|
|
98
280
|
.ifNotExists()
|