@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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/dist/blobs/index.js +3 -3
  3. package/dist/client.d.ts +10 -5
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +70 -21
  6. package/dist/client.js.map +1 -1
  7. package/dist/conflicts.d.ts.map +1 -1
  8. package/dist/conflicts.js +1 -7
  9. package/dist/conflicts.js.map +1 -1
  10. package/dist/create-client.d.ts +5 -1
  11. package/dist/create-client.d.ts.map +1 -1
  12. package/dist/create-client.js +22 -10
  13. package/dist/create-client.js.map +1 -1
  14. package/dist/engine/SyncEngine.d.ts +24 -2
  15. package/dist/engine/SyncEngine.d.ts.map +1 -1
  16. package/dist/engine/SyncEngine.js +290 -43
  17. package/dist/engine/SyncEngine.js.map +1 -1
  18. package/dist/engine/index.js +2 -2
  19. package/dist/engine/types.d.ts +16 -4
  20. package/dist/engine/types.d.ts.map +1 -1
  21. package/dist/handlers/create-handler.d.ts +15 -5
  22. package/dist/handlers/create-handler.d.ts.map +1 -1
  23. package/dist/handlers/create-handler.js +35 -24
  24. package/dist/handlers/create-handler.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -5
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/index.js +19 -19
  28. package/dist/migrate.d.ts +1 -1
  29. package/dist/migrate.d.ts.map +1 -1
  30. package/dist/migrate.js +148 -28
  31. package/dist/migrate.js.map +1 -1
  32. package/dist/mutations.d.ts +3 -1
  33. package/dist/mutations.d.ts.map +1 -1
  34. package/dist/mutations.js +93 -18
  35. package/dist/mutations.js.map +1 -1
  36. package/dist/outbox.d.ts.map +1 -1
  37. package/dist/outbox.js +1 -11
  38. package/dist/outbox.js.map +1 -1
  39. package/dist/plugins/incrementing-version.d.ts +1 -1
  40. package/dist/plugins/incrementing-version.js +2 -2
  41. package/dist/plugins/index.js +2 -2
  42. package/dist/proxy/dialect.js +1 -1
  43. package/dist/proxy/driver.js +1 -1
  44. package/dist/proxy/index.js +4 -4
  45. package/dist/proxy/mutations.js +1 -1
  46. package/dist/pull-engine.d.ts +29 -3
  47. package/dist/pull-engine.d.ts.map +1 -1
  48. package/dist/pull-engine.js +314 -78
  49. package/dist/pull-engine.js.map +1 -1
  50. package/dist/push-engine.d.ts.map +1 -1
  51. package/dist/push-engine.js +28 -3
  52. package/dist/push-engine.js.map +1 -1
  53. package/dist/query/QueryContext.js +1 -1
  54. package/dist/query/index.js +3 -3
  55. package/dist/query/tracked-select.d.ts +2 -1
  56. package/dist/query/tracked-select.d.ts.map +1 -1
  57. package/dist/query/tracked-select.js +1 -1
  58. package/dist/schema.d.ts +2 -2
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/sync-loop.d.ts +5 -1
  61. package/dist/sync-loop.d.ts.map +1 -1
  62. package/dist/sync-loop.js +167 -18
  63. package/dist/sync-loop.js.map +1 -1
  64. package/package.json +30 -6
  65. package/src/client.test.ts +369 -0
  66. package/src/client.ts +101 -22
  67. package/src/conflicts.ts +1 -10
  68. package/src/create-client.ts +33 -5
  69. package/src/engine/SyncEngine.test.ts +157 -0
  70. package/src/engine/SyncEngine.ts +359 -40
  71. package/src/engine/types.ts +22 -4
  72. package/src/handlers/create-handler.ts +86 -37
  73. package/src/handlers/types.ts +10 -4
  74. package/src/migrate.ts +215 -33
  75. package/src/mutations.ts +143 -21
  76. package/src/outbox.ts +1 -15
  77. package/src/plugins/incrementing-version.ts +2 -2
  78. package/src/pull-engine.test.ts +147 -0
  79. package/src/pull-engine.ts +392 -77
  80. package/src/push-engine.ts +33 -1
  81. package/src/query/tracked-select.ts +1 -1
  82. package/src/schema.ts +2 -2
  83. 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 { ScopeDefinition, SyncChange, SyncSnapshot } from '@syncular/core';
6
- import { normalizeScopes } from '@syncular/core';
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
- function isRecord(value: unknown): value is Record<string, unknown> {
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: ScopeDefinition[];
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?: Record<string, string | string[]>;
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
- await sql`
198
- insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
199
- values ${sql.join(
200
- rows.map(
201
- (row) =>
202
- sql`(${sql.join(
203
- columns.map((col) => sql.val(coerceForSql(row[col]))),
204
- sql`, `
205
- )})`
206
- ),
207
- sql`, `
208
- )}
209
- on conflict (${sql.ref(primaryKey)}) ${onConflict}
210
- `.execute(ctx.trx);
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) ? 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(coerceForSql(insertRow[col]))),
304
+ columns.map((col) => sql.val(insertRow[col] ?? null)),
256
305
  sql`, `
257
306
  )})
258
307
  on conflict (${sql.ref(primaryKey)}) ${onConflict}
@@ -2,7 +2,12 @@
2
2
  * @syncular/client - Sync client table handler interface
3
3
  */
4
4
 
5
- import type { ScopeValues, SyncChange, SyncSnapshot } from '@syncular/core';
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?: Record<string, string | string[]>;
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 { Kysely } from 'kysely';
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('shape', 'text', (col) => col.notNull())
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()