@syncular/client 0.0.1-100

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 (178) hide show
  1. package/dist/blobs/index.d.ts +7 -0
  2. package/dist/blobs/index.d.ts.map +1 -0
  3. package/dist/blobs/index.js +7 -0
  4. package/dist/blobs/index.js.map +1 -0
  5. package/dist/blobs/manager.d.ts +345 -0
  6. package/dist/blobs/manager.d.ts.map +1 -0
  7. package/dist/blobs/manager.js +749 -0
  8. package/dist/blobs/manager.js.map +1 -0
  9. package/dist/blobs/migrate.d.ts +14 -0
  10. package/dist/blobs/migrate.d.ts.map +1 -0
  11. package/dist/blobs/migrate.js +59 -0
  12. package/dist/blobs/migrate.js.map +1 -0
  13. package/dist/blobs/types.d.ts +62 -0
  14. package/dist/blobs/types.d.ts.map +1 -0
  15. package/dist/blobs/types.js +5 -0
  16. package/dist/blobs/types.js.map +1 -0
  17. package/dist/client.d.ts +339 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +881 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/conflicts.d.ts +31 -0
  22. package/dist/conflicts.d.ts.map +1 -0
  23. package/dist/conflicts.js +112 -0
  24. package/dist/conflicts.js.map +1 -0
  25. package/dist/create-client.d.ts +115 -0
  26. package/dist/create-client.d.ts.map +1 -0
  27. package/dist/create-client.js +162 -0
  28. package/dist/create-client.js.map +1 -0
  29. package/dist/engine/SyncEngine.d.ts +216 -0
  30. package/dist/engine/SyncEngine.d.ts.map +1 -0
  31. package/dist/engine/SyncEngine.js +1141 -0
  32. package/dist/engine/SyncEngine.js.map +1 -0
  33. package/dist/engine/index.d.ts +6 -0
  34. package/dist/engine/index.d.ts.map +1 -0
  35. package/dist/engine/index.js +6 -0
  36. package/dist/engine/index.js.map +1 -0
  37. package/dist/engine/types.d.ts +230 -0
  38. package/dist/engine/types.d.ts.map +1 -0
  39. package/dist/engine/types.js +7 -0
  40. package/dist/engine/types.js.map +1 -0
  41. package/dist/handlers/create-handler.d.ts +110 -0
  42. package/dist/handlers/create-handler.d.ts.map +1 -0
  43. package/dist/handlers/create-handler.js +142 -0
  44. package/dist/handlers/create-handler.js.map +1 -0
  45. package/dist/handlers/registry.d.ts +15 -0
  46. package/dist/handlers/registry.d.ts.map +1 -0
  47. package/dist/handlers/registry.js +29 -0
  48. package/dist/handlers/registry.js.map +1 -0
  49. package/dist/handlers/types.d.ts +83 -0
  50. package/dist/handlers/types.d.ts.map +1 -0
  51. package/dist/handlers/types.js +5 -0
  52. package/dist/handlers/types.js.map +1 -0
  53. package/dist/index.d.ts +24 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +24 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/migrate.d.ts +19 -0
  58. package/dist/migrate.d.ts.map +1 -0
  59. package/dist/migrate.js +106 -0
  60. package/dist/migrate.js.map +1 -0
  61. package/dist/mutations.d.ts +138 -0
  62. package/dist/mutations.d.ts.map +1 -0
  63. package/dist/mutations.js +601 -0
  64. package/dist/mutations.js.map +1 -0
  65. package/dist/outbox.d.ts +112 -0
  66. package/dist/outbox.d.ts.map +1 -0
  67. package/dist/outbox.js +294 -0
  68. package/dist/outbox.js.map +1 -0
  69. package/dist/plugins/incrementing-version.d.ts +34 -0
  70. package/dist/plugins/incrementing-version.d.ts.map +1 -0
  71. package/dist/plugins/incrementing-version.js +83 -0
  72. package/dist/plugins/incrementing-version.js.map +1 -0
  73. package/dist/plugins/index.d.ts +3 -0
  74. package/dist/plugins/index.d.ts.map +1 -0
  75. package/dist/plugins/index.js +3 -0
  76. package/dist/plugins/index.js.map +1 -0
  77. package/dist/plugins/types.d.ts +49 -0
  78. package/dist/plugins/types.d.ts.map +1 -0
  79. package/dist/plugins/types.js +15 -0
  80. package/dist/plugins/types.js.map +1 -0
  81. package/dist/proxy/connection.d.ts +33 -0
  82. package/dist/proxy/connection.d.ts.map +1 -0
  83. package/dist/proxy/connection.js +153 -0
  84. package/dist/proxy/connection.js.map +1 -0
  85. package/dist/proxy/dialect.d.ts +46 -0
  86. package/dist/proxy/dialect.d.ts.map +1 -0
  87. package/dist/proxy/dialect.js +58 -0
  88. package/dist/proxy/dialect.js.map +1 -0
  89. package/dist/proxy/driver.d.ts +42 -0
  90. package/dist/proxy/driver.d.ts.map +1 -0
  91. package/dist/proxy/driver.js +78 -0
  92. package/dist/proxy/driver.js.map +1 -0
  93. package/dist/proxy/index.d.ts +10 -0
  94. package/dist/proxy/index.d.ts.map +1 -0
  95. package/dist/proxy/index.js +10 -0
  96. package/dist/proxy/index.js.map +1 -0
  97. package/dist/proxy/mutations.d.ts +9 -0
  98. package/dist/proxy/mutations.d.ts.map +1 -0
  99. package/dist/proxy/mutations.js +11 -0
  100. package/dist/proxy/mutations.js.map +1 -0
  101. package/dist/pull-engine.d.ts +45 -0
  102. package/dist/pull-engine.d.ts.map +1 -0
  103. package/dist/pull-engine.js +381 -0
  104. package/dist/pull-engine.js.map +1 -0
  105. package/dist/push-engine.d.ts +18 -0
  106. package/dist/push-engine.d.ts.map +1 -0
  107. package/dist/push-engine.js +155 -0
  108. package/dist/push-engine.js.map +1 -0
  109. package/dist/query/FingerprintCollector.d.ts +18 -0
  110. package/dist/query/FingerprintCollector.d.ts.map +1 -0
  111. package/dist/query/FingerprintCollector.js +28 -0
  112. package/dist/query/FingerprintCollector.js.map +1 -0
  113. package/dist/query/QueryContext.d.ts +33 -0
  114. package/dist/query/QueryContext.d.ts.map +1 -0
  115. package/dist/query/QueryContext.js +16 -0
  116. package/dist/query/QueryContext.js.map +1 -0
  117. package/dist/query/fingerprint.d.ts +61 -0
  118. package/dist/query/fingerprint.d.ts.map +1 -0
  119. package/dist/query/fingerprint.js +91 -0
  120. package/dist/query/fingerprint.js.map +1 -0
  121. package/dist/query/index.d.ts +7 -0
  122. package/dist/query/index.d.ts.map +1 -0
  123. package/dist/query/index.js +7 -0
  124. package/dist/query/index.js.map +1 -0
  125. package/dist/query/tracked-select.d.ts +18 -0
  126. package/dist/query/tracked-select.d.ts.map +1 -0
  127. package/dist/query/tracked-select.js +90 -0
  128. package/dist/query/tracked-select.js.map +1 -0
  129. package/dist/schema.d.ts +83 -0
  130. package/dist/schema.d.ts.map +1 -0
  131. package/dist/schema.js +7 -0
  132. package/dist/schema.js.map +1 -0
  133. package/dist/sync-loop.d.ts +32 -0
  134. package/dist/sync-loop.d.ts.map +1 -0
  135. package/dist/sync-loop.js +249 -0
  136. package/dist/sync-loop.js.map +1 -0
  137. package/dist/utils/id.d.ts +8 -0
  138. package/dist/utils/id.d.ts.map +1 -0
  139. package/dist/utils/id.js +19 -0
  140. package/dist/utils/id.js.map +1 -0
  141. package/package.json +59 -0
  142. package/src/blobs/index.ts +7 -0
  143. package/src/blobs/manager.ts +1027 -0
  144. package/src/blobs/migrate.ts +67 -0
  145. package/src/blobs/types.ts +84 -0
  146. package/src/client.test.ts +369 -0
  147. package/src/client.ts +1288 -0
  148. package/src/conflicts.ts +171 -0
  149. package/src/create-client.ts +297 -0
  150. package/src/engine/SyncEngine.test.ts +157 -0
  151. package/src/engine/SyncEngine.ts +1464 -0
  152. package/src/engine/index.ts +6 -0
  153. package/src/engine/types.ts +268 -0
  154. package/src/handlers/create-handler.ts +298 -0
  155. package/src/handlers/registry.ts +36 -0
  156. package/src/handlers/types.ts +102 -0
  157. package/src/index.ts +25 -0
  158. package/src/migrate.ts +122 -0
  159. package/src/mutations.ts +912 -0
  160. package/src/outbox.ts +383 -0
  161. package/src/plugins/incrementing-version.ts +133 -0
  162. package/src/plugins/index.ts +2 -0
  163. package/src/plugins/types.ts +63 -0
  164. package/src/proxy/connection.ts +191 -0
  165. package/src/proxy/dialect.ts +76 -0
  166. package/src/proxy/driver.ts +126 -0
  167. package/src/proxy/index.ts +10 -0
  168. package/src/proxy/mutations.ts +18 -0
  169. package/src/pull-engine.ts +508 -0
  170. package/src/push-engine.ts +201 -0
  171. package/src/query/FingerprintCollector.ts +29 -0
  172. package/src/query/QueryContext.ts +54 -0
  173. package/src/query/fingerprint.ts +109 -0
  174. package/src/query/index.ts +10 -0
  175. package/src/query/tracked-select.ts +139 -0
  176. package/src/schema.ts +94 -0
  177. package/src/sync-loop.ts +368 -0
  178. package/src/utils/id.ts +20 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * @syncular/client - Mutations API (Proxy-based, typed)
3
+ *
4
+ * Provides a dynamic `Proxy` mutation interface with Kysely typings:
5
+ * - `mutations.tasks.insert({ ... })` (auto-generates id)
6
+ * - `mutations.tasks.update(id, { ... })`
7
+ * - `mutations.tasks.delete(id)`
8
+ * - `mutations.$commit(async (tx) => { ... })` for batching
9
+ *
10
+ * Under the hood this compiles to sync `upsert/delete` operations.
11
+ *
12
+ * This module is framework-agnostic. `@syncular/client-react` wraps it to add
13
+ * React state and automatic sync triggering.
14
+ */
15
+
16
+ import type {
17
+ SyncOperation,
18
+ SyncPushRequest,
19
+ SyncPushResponse,
20
+ SyncTransport,
21
+ } from '@syncular/core';
22
+ import { isRecord, randomId } from '@syncular/core';
23
+ import type { Insertable, Kysely, Transaction, Updateable } from 'kysely';
24
+ import { sql } from 'kysely';
25
+ import { enqueueOutboxCommit } from './outbox';
26
+ import type {
27
+ SyncClientPlugin,
28
+ SyncClientPluginContext,
29
+ } from './plugins/types';
30
+ import type { SyncClientDb } from './schema';
31
+
32
+ /**
33
+ * Base type for any database schema.
34
+ * Uses an index signature to support runtime-determined table names.
35
+ */
36
+ type AnyDb = Record<string, Record<string, unknown>>;
37
+
38
+ type SyncOpKind = 'upsert' | 'delete';
39
+
40
+ type ReservedKeys = '$commit' | '$table';
41
+ type KnownKeys<T> = string extends keyof T ? never : keyof T & string;
42
+ type KnownTableKey<DB> = Exclude<KnownKeys<DB>, ReservedKeys>;
43
+
44
+ type InsertPayload<Row> =
45
+ Insertable<Row> extends { id?: infer I }
46
+ ? Omit<Insertable<Row>, 'id'> & { id?: I }
47
+ : Insertable<Row>;
48
+
49
+ type UpdatePayload<Row> = Omit<Updateable<Row>, 'id'> & { id?: never };
50
+
51
+ type BaseVersionOptions = { baseVersion?: number | null };
52
+
53
+ export interface MutationReceipt {
54
+ /**
55
+ * Outbox commit id (when using outbox) or a generated id (when pushing directly).
56
+ */
57
+ commitId: string;
58
+ /**
59
+ * Protocol-level client commit id (sent to the server in push requests).
60
+ */
61
+ clientCommitId: string;
62
+ }
63
+
64
+ export interface OutboxCommitMeta {
65
+ operations: SyncOperation[];
66
+ localMutations: Array<{ table: string; rowId: string; op: SyncOpKind }>;
67
+ }
68
+
69
+ export interface PushCommitMeta {
70
+ operations: SyncOperation[];
71
+ localMutations: Array<{ table: string; rowId: string; op: SyncOpKind }>;
72
+ response: SyncPushResponse;
73
+ }
74
+
75
+ export type TableMutations<DB, T extends keyof DB & string> = {
76
+ insert: (
77
+ values: InsertPayload<DB[T]>
78
+ ) => Promise<MutationReceipt & { id: string }>;
79
+ insertMany: (
80
+ rows: Array<InsertPayload<DB[T]>>
81
+ ) => Promise<MutationReceipt & { ids: string[] }>;
82
+ update: (
83
+ id: string,
84
+ patch: UpdatePayload<DB[T]>,
85
+ options?: BaseVersionOptions
86
+ ) => Promise<MutationReceipt>;
87
+ delete: (
88
+ id: string,
89
+ options?: BaseVersionOptions
90
+ ) => Promise<MutationReceipt>;
91
+ /**
92
+ * Explicit upsert escape hatch. Prefer insert/update for clarity.
93
+ */
94
+ upsert: (
95
+ id: string,
96
+ patch: UpdatePayload<DB[T]>,
97
+ options?: BaseVersionOptions
98
+ ) => Promise<MutationReceipt>;
99
+ };
100
+
101
+ export type TableMutationsTx<DB, T extends keyof DB & string> = {
102
+ insert: (values: InsertPayload<DB[T]>) => Promise<string>;
103
+ insertMany: (rows: Array<InsertPayload<DB[T]>>) => Promise<string[]>;
104
+ update: (
105
+ id: string,
106
+ patch: UpdatePayload<DB[T]>,
107
+ options?: BaseVersionOptions
108
+ ) => Promise<void>;
109
+ delete: (id: string, options?: BaseVersionOptions) => Promise<void>;
110
+ upsert: (
111
+ id: string,
112
+ patch: UpdatePayload<DB[T]>,
113
+ options?: BaseVersionOptions
114
+ ) => Promise<void>;
115
+ };
116
+
117
+ export type MutationsTx<DB> = {
118
+ [T in KnownTableKey<DB>]: TableMutationsTx<DB, T>;
119
+ } & {
120
+ // Index signature for dynamic access via Proxy
121
+ [table: string]: TableMutationsTx<AnyDb, string>;
122
+ };
123
+
124
+ export type MutationsCommitFn<DB, Meta = unknown, Options = unknown> = <R>(
125
+ fn: (tx: MutationsTx<DB>) => Promise<R> | R,
126
+ options?: Options
127
+ ) => Promise<{ result: R; receipt: MutationReceipt; meta: Meta }>;
128
+
129
+ export type MutationsApi<DB, CommitOptions = unknown> = {
130
+ $commit: <R>(
131
+ fn: (tx: MutationsTx<DB>) => Promise<R> | R,
132
+ options?: CommitOptions
133
+ ) => Promise<{ result: R; commit: MutationReceipt }>;
134
+ $table: {
135
+ <T extends KnownTableKey<DB>>(table: T): TableMutations<DB, T>;
136
+ (table: string): TableMutations<AnyDb, string>;
137
+ };
138
+ } & {
139
+ [T in KnownTableKey<DB>]: TableMutations<DB, T>;
140
+ };
141
+
142
+ function sanitizePayload(
143
+ payload: Record<string, unknown>,
144
+ args: { omit: string[] }
145
+ ): Record<string, unknown> {
146
+ if (args.omit.length === 0) return payload;
147
+ const omitSet = new Set(args.omit);
148
+ let changed = false;
149
+ const out: Record<string, unknown> = {};
150
+ for (const [k, v] of Object.entries(payload)) {
151
+ if (omitSet.has(k)) {
152
+ changed = true;
153
+ continue;
154
+ }
155
+ out[k] = v;
156
+ }
157
+ return changed ? out : payload;
158
+ }
159
+
160
+ function coerceBaseVersion(value: unknown): number | null {
161
+ if (value === null || value === undefined) return null;
162
+ const n = typeof value === 'number' ? value : Number(value);
163
+ if (!Number.isFinite(n)) return null;
164
+ if (n <= 0) return null;
165
+ return n;
166
+ }
167
+
168
+ function hasOwn(obj: object, key: string): boolean {
169
+ return Object.hasOwn(obj, key);
170
+ }
171
+
172
+ /**
173
+ * Valid SQL identifier regex.
174
+ * Allows: alphanumeric, underscore, cannot start with digit
175
+ */
176
+ const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
177
+
178
+ /**
179
+ * Validate table name to prevent SQL injection.
180
+ * Table names are inserted directly into SQL, so they must be validated.
181
+ */
182
+ function validateTableName(table: string): void {
183
+ if (!VALID_IDENTIFIER_REGEX.test(table)) {
184
+ throw new Error(
185
+ `Invalid table name "${table}". Table names must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`
186
+ );
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Internal helpers for dynamic table operations.
192
+ *
193
+ * Kysely's query builder is typed around compile-time table names. For the
194
+ * Proxy-based mutations API, we need runtime table/column names. We use
195
+ * Kysely's `sql` builder with strict identifier validation to avoid unsafe
196
+ * `any`/`unknown` casts while keeping the public API typed.
197
+ */
198
+ function validateColumnName(column: string): void {
199
+ if (!VALID_IDENTIFIER_REGEX.test(column)) {
200
+ throw new Error(
201
+ `Invalid column name "${column}". Column names must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`
202
+ );
203
+ }
204
+ }
205
+
206
+ async function readBaseVersion<T>(args: {
207
+ trx: Transaction<T>;
208
+ table: string;
209
+ rowId: string;
210
+ idColumn: string;
211
+ versionColumn: string;
212
+ }): Promise<number | null> {
213
+ validateTableName(args.table);
214
+ validateColumnName(args.idColumn);
215
+ validateColumnName(args.versionColumn);
216
+
217
+ const res = await sql<{ v: unknown }>`
218
+ select ${sql.ref(args.versionColumn)} as v
219
+ from ${sql.table(args.table)}
220
+ where ${sql.ref(args.idColumn)} = ${sql.val(args.rowId)}
221
+ limit 1
222
+ `.execute(args.trx);
223
+
224
+ return coerceBaseVersion(res.rows[0]?.v);
225
+ }
226
+
227
+ async function dynamicInsert<T>(
228
+ trx: Transaction<T>,
229
+ table: string,
230
+ values: Record<string, unknown> | Record<string, unknown>[]
231
+ ): Promise<void> {
232
+ validateTableName(table);
233
+ const rows = Array.isArray(values) ? values : [values];
234
+ if (rows.length === 0) return;
235
+
236
+ const columnsSet = new Set<string>();
237
+ for (const row of rows) {
238
+ for (const col of Object.keys(row)) {
239
+ validateColumnName(col);
240
+ columnsSet.add(col);
241
+ }
242
+ }
243
+
244
+ const columns = Array.from(columnsSet);
245
+ if (columns.length === 0) return;
246
+
247
+ await sql`
248
+ insert into ${sql.table(table)} (${sql.join(columns.map((c) => sql.ref(c)))})
249
+ values ${sql.join(
250
+ rows.map(
251
+ (row) => sql`(${sql.join(columns.map((c) => sql.val(row[c] ?? null)))})`
252
+ )
253
+ )}
254
+ `.execute(trx);
255
+ }
256
+
257
+ async function dynamicUpsert<T>(
258
+ trx: Transaction<T>,
259
+ table: string,
260
+ idColumn: string,
261
+ id: string,
262
+ values: Record<string, unknown>
263
+ ): Promise<void> {
264
+ validateTableName(table);
265
+ validateColumnName(idColumn);
266
+
267
+ // Check if the row already exists
268
+ const existing = await sql`
269
+ select 1 from ${sql.table(table)}
270
+ where ${sql.ref(idColumn)} = ${sql.val(id)}
271
+ limit 1
272
+ `.execute(trx);
273
+
274
+ if (existing.rows.length > 0) {
275
+ // Row exists: just update the provided columns
276
+ await dynamicUpdate(trx, table, idColumn, id, values);
277
+ } else {
278
+ // Row doesn't exist: insert with all provided columns + id
279
+ await dynamicInsert(trx, table, { ...values, [idColumn]: id });
280
+ }
281
+ }
282
+
283
+ async function dynamicUpdate<T>(
284
+ trx: Transaction<T>,
285
+ table: string,
286
+ idColumn: string,
287
+ id: string,
288
+ values: Record<string, unknown>
289
+ ): Promise<void> {
290
+ validateTableName(table);
291
+ validateColumnName(idColumn);
292
+
293
+ const setParts = Object.entries(values).map(([col, value]) => {
294
+ validateColumnName(col);
295
+ return sql`${sql.ref(col)} = ${sql.val(value)}`;
296
+ });
297
+
298
+ if (setParts.length === 0) return;
299
+
300
+ await sql`
301
+ update ${sql.table(table)}
302
+ set ${sql.join(setParts)}
303
+ where ${sql.ref(idColumn)} = ${sql.val(id)}
304
+ `.execute(trx);
305
+ }
306
+
307
+ async function dynamicDelete<T>(
308
+ trx: Transaction<T>,
309
+ table: string,
310
+ idColumn: string,
311
+ id: string
312
+ ): Promise<void> {
313
+ validateTableName(table);
314
+ validateColumnName(idColumn);
315
+ await sql`
316
+ delete from ${sql.table(table)}
317
+ where ${sql.ref(idColumn)} = ${sql.val(id)}
318
+ `.execute(trx);
319
+ }
320
+
321
+ export function createMutationsApi<DB, Meta = unknown, CommitOptions = unknown>(
322
+ commit: MutationsCommitFn<DB, Meta, CommitOptions>
323
+ ): MutationsApi<DB, CommitOptions> {
324
+ const rootTableCache = new Map<string, any>();
325
+
326
+ const apiBase = {
327
+ $commit: async <R>(
328
+ fn: (tx: MutationsTx<DB>) => Promise<R> | R,
329
+ options?: CommitOptions
330
+ ) => {
331
+ const { result, receipt } = await commit(fn, options);
332
+ return { result, commit: receipt };
333
+ },
334
+ $table: (table: string) => {
335
+ const cached = rootTableCache.get(table);
336
+ if (cached) return cached;
337
+
338
+ const tableApi: TableMutations<any, any> = {
339
+ async insert(values) {
340
+ const { result, receipt } = await commit(
341
+ async (tx) => await tx[table]!.insert(values)
342
+ );
343
+ return { ...receipt, id: result };
344
+ },
345
+ async insertMany(rows) {
346
+ const { result, receipt } = await commit(
347
+ async (tx) => await tx[table]!.insertMany(rows)
348
+ );
349
+ return { ...receipt, ids: result };
350
+ },
351
+ async update(id, patch, opts) {
352
+ const { receipt } = await commit(async (tx) => {
353
+ await tx[table]!.update(id, patch, opts);
354
+ return null;
355
+ });
356
+ return receipt;
357
+ },
358
+ async delete(id, opts) {
359
+ const { receipt } = await commit(async (tx) => {
360
+ await tx[table]!.delete(id, opts);
361
+ return null;
362
+ });
363
+ return receipt;
364
+ },
365
+ async upsert(id, patch, opts) {
366
+ const { receipt } = await commit(async (tx) => {
367
+ await tx[table]!.upsert(id, patch, opts);
368
+ return null;
369
+ });
370
+ return receipt;
371
+ },
372
+ };
373
+
374
+ rootTableCache.set(table, tableApi);
375
+ return tableApi;
376
+ },
377
+ };
378
+
379
+ return new Proxy(apiBase, {
380
+ get(target, prop) {
381
+ if (prop === 'then') return undefined;
382
+ if (typeof prop !== 'string') return undefined;
383
+ if (hasOwn(target, prop)) {
384
+ return (target as Record<string, unknown>)[prop];
385
+ }
386
+ return target.$table(prop);
387
+ },
388
+ }) as MutationsApi<DB, CommitOptions>;
389
+ }
390
+
391
+ export interface OutboxCommitConfig<DB extends SyncClientDb> {
392
+ db: Kysely<DB>;
393
+ idColumn?: string;
394
+ versionColumn?: string | null;
395
+ omitColumns?: string[];
396
+ }
397
+
398
+ export function createOutboxCommit<DB extends SyncClientDb>(
399
+ config: OutboxCommitConfig<DB>
400
+ ): MutationsCommitFn<DB, OutboxCommitMeta, undefined> {
401
+ const idColumn = config.idColumn ?? 'id';
402
+ const versionColumn = config.versionColumn ?? 'server_version';
403
+ const omitColumns = config.omitColumns ?? [];
404
+
405
+ return async (fn) => {
406
+ const operations: SyncOperation[] = [];
407
+ const localMutations: Array<{
408
+ table: string;
409
+ rowId: string;
410
+ op: SyncOpKind;
411
+ }> = [];
412
+
413
+ const { result, receipt } = await config.db
414
+ .transaction()
415
+ .execute(async (trx) => {
416
+ const txTableCache = new Map<string, any>();
417
+
418
+ const makeTxTable = (table: string) => {
419
+ const cached = txTableCache.get(table);
420
+ if (cached) return cached;
421
+
422
+ const tableApi: TableMutationsTx<any, any> = {
423
+ async insert(values) {
424
+ const raw = isRecord(values) ? values : {};
425
+ const rawId = raw[idColumn];
426
+ const id =
427
+ typeof rawId === 'string' && rawId ? rawId : randomId();
428
+
429
+ const row = { ...raw, [idColumn]: id };
430
+
431
+ await dynamicInsert(trx, table, row);
432
+
433
+ const payload = sanitizePayload(row, {
434
+ omit: [
435
+ idColumn,
436
+ ...(versionColumn ? [versionColumn] : []),
437
+ ...omitColumns,
438
+ ],
439
+ });
440
+
441
+ operations.push({
442
+ table: table,
443
+ row_id: id,
444
+ op: 'upsert',
445
+ payload,
446
+ base_version: null,
447
+ });
448
+
449
+ localMutations.push({ table, rowId: id, op: 'upsert' });
450
+ return id;
451
+ },
452
+
453
+ async insertMany(rows) {
454
+ const ids: string[] = [];
455
+ const toInsert: Record<string, unknown>[] = [];
456
+
457
+ for (const values of rows) {
458
+ const raw = isRecord(values) ? values : {};
459
+ const rawId = raw[idColumn];
460
+ const id =
461
+ typeof rawId === 'string' && rawId ? rawId : randomId();
462
+ ids.push(id);
463
+ toInsert.push({ ...raw, [idColumn]: id });
464
+ }
465
+
466
+ if (toInsert.length > 0) {
467
+ await dynamicInsert(trx, table, toInsert);
468
+ }
469
+
470
+ for (let i = 0; i < toInsert.length; i++) {
471
+ const row = toInsert[i]!;
472
+ const id = ids[i]!;
473
+ const payload = sanitizePayload(row, {
474
+ omit: [
475
+ idColumn,
476
+ ...(versionColumn ? [versionColumn] : []),
477
+ ...omitColumns,
478
+ ],
479
+ });
480
+
481
+ operations.push({
482
+ table: table,
483
+ row_id: id,
484
+ op: 'upsert',
485
+ payload,
486
+ base_version: null,
487
+ });
488
+
489
+ localMutations.push({ table, rowId: id, op: 'upsert' });
490
+ }
491
+
492
+ return ids;
493
+ },
494
+
495
+ async update(id, patch, opts) {
496
+ const rawPatch = isRecord(patch) ? patch : {};
497
+ const sanitized = sanitizePayload(rawPatch, {
498
+ omit: [
499
+ idColumn,
500
+ ...(versionColumn ? [versionColumn] : []),
501
+ ...omitColumns,
502
+ ],
503
+ });
504
+
505
+ const hasExplicitBaseVersion =
506
+ !!opts && hasOwn(opts, 'baseVersion');
507
+
508
+ await dynamicUpdate(trx, table, idColumn, id, sanitized);
509
+
510
+ const baseVersion = hasExplicitBaseVersion
511
+ ? (opts!.baseVersion ?? null)
512
+ : versionColumn
513
+ ? await readBaseVersion({
514
+ trx: trx,
515
+ table,
516
+ rowId: id,
517
+ idColumn,
518
+ versionColumn,
519
+ })
520
+ : null;
521
+
522
+ operations.push({
523
+ table: table,
524
+ row_id: id,
525
+ op: 'upsert',
526
+ payload: sanitized,
527
+ base_version: coerceBaseVersion(baseVersion),
528
+ });
529
+
530
+ localMutations.push({ table, rowId: id, op: 'upsert' });
531
+ },
532
+
533
+ async delete(id, opts) {
534
+ const hasExplicitBaseVersion =
535
+ !!opts && hasOwn(opts, 'baseVersion');
536
+ const baseVersion = hasExplicitBaseVersion
537
+ ? (opts!.baseVersion ?? null)
538
+ : versionColumn
539
+ ? await readBaseVersion({
540
+ trx: trx,
541
+ table,
542
+ rowId: id,
543
+ idColumn,
544
+ versionColumn,
545
+ })
546
+ : null;
547
+
548
+ await dynamicDelete(trx, table, idColumn, id);
549
+
550
+ operations.push({
551
+ table: table,
552
+ row_id: id,
553
+ op: 'delete',
554
+ payload: null,
555
+ base_version: coerceBaseVersion(baseVersion),
556
+ });
557
+
558
+ localMutations.push({ table, rowId: id, op: 'delete' });
559
+ },
560
+
561
+ async upsert(id, patch, opts) {
562
+ const rawPatch = isRecord(patch) ? patch : {};
563
+ const sanitized = sanitizePayload(rawPatch, {
564
+ omit: [
565
+ idColumn,
566
+ ...(versionColumn ? [versionColumn] : []),
567
+ ...omitColumns,
568
+ ],
569
+ });
570
+
571
+ const hasExplicitBaseVersion =
572
+ !!opts && hasOwn(opts, 'baseVersion');
573
+
574
+ await dynamicUpsert(trx, table, idColumn, id, sanitized);
575
+
576
+ const baseVersion = hasExplicitBaseVersion
577
+ ? (opts!.baseVersion ?? null)
578
+ : versionColumn
579
+ ? await readBaseVersion({
580
+ trx: trx,
581
+ table,
582
+ rowId: id,
583
+ idColumn,
584
+ versionColumn,
585
+ })
586
+ : null;
587
+
588
+ operations.push({
589
+ table: table,
590
+ row_id: id,
591
+ op: 'upsert',
592
+ payload: sanitized,
593
+ base_version: coerceBaseVersion(baseVersion),
594
+ });
595
+
596
+ localMutations.push({ table, rowId: id, op: 'upsert' });
597
+ },
598
+ };
599
+
600
+ txTableCache.set(table, tableApi);
601
+ return tableApi;
602
+ };
603
+
604
+ const txProxy = new Proxy(
605
+ {},
606
+ {
607
+ get(_target, prop) {
608
+ if (prop === 'then') return undefined;
609
+ if (typeof prop !== 'string') return undefined;
610
+ return makeTxTable(prop);
611
+ },
612
+ }
613
+ ) as MutationsTx<DB>;
614
+
615
+ const result = await fn(txProxy);
616
+
617
+ if (operations.length === 0) {
618
+ throw new Error('No mutations were enqueued');
619
+ }
620
+
621
+ // Enqueue outbox commit within this transaction
622
+ const receipt = await enqueueOutboxCommit(trx, { operations });
623
+ return {
624
+ result,
625
+ receipt: { id: receipt.id, clientCommitId: receipt.clientCommitId },
626
+ };
627
+ });
628
+
629
+ return {
630
+ result,
631
+ receipt: { commitId: receipt.id, clientCommitId: receipt.clientCommitId },
632
+ meta: { operations, localMutations },
633
+ };
634
+ };
635
+ }
636
+
637
+ export function createOutboxMutations<DB extends SyncClientDb>(
638
+ config: OutboxCommitConfig<DB>
639
+ ): MutationsApi<DB, undefined> {
640
+ return createMutationsApi(createOutboxCommit(config));
641
+ }
642
+
643
+ export interface PushCommitConfig {
644
+ transport: SyncTransport;
645
+ clientId: string;
646
+ actorId?: string;
647
+ plugins?: SyncClientPlugin[];
648
+ idColumn?: string;
649
+ versionColumn?: string | null;
650
+ omitColumns?: string[];
651
+ /** Client schema version (default: 1) */
652
+ schemaVersion?: number;
653
+ readBaseVersion?: (args: {
654
+ table: string;
655
+ rowId: string;
656
+ idColumn: string;
657
+ versionColumn: string;
658
+ }) => Promise<number | null>;
659
+ }
660
+
661
+ function clonePushRequest(request: SyncPushRequest): SyncPushRequest {
662
+ if (typeof structuredClone === 'function') return structuredClone(request);
663
+ return JSON.parse(JSON.stringify(request)) as SyncPushRequest;
664
+ }
665
+
666
+ export function createPushCommit<DB = AnyDb>(
667
+ config: PushCommitConfig
668
+ ): MutationsCommitFn<DB, PushCommitMeta, undefined> {
669
+ const idColumn = config.idColumn ?? 'id';
670
+ const versionColumn = config.versionColumn ?? 'server_version';
671
+ const omitColumns = config.omitColumns ?? [];
672
+
673
+ return async (fn) => {
674
+ const operations: SyncOperation[] = [];
675
+ const localMutations: Array<{
676
+ table: string;
677
+ rowId: string;
678
+ op: SyncOpKind;
679
+ }> = [];
680
+
681
+ const txTableCache = new Map<string, any>();
682
+ const makeTxTable = (table: string) => {
683
+ const cached = txTableCache.get(table);
684
+ if (cached) return cached;
685
+
686
+ const tableApi: TableMutationsTx<any, any> = {
687
+ async insert(values) {
688
+ const raw = isRecord(values) ? values : {};
689
+ const rawId = raw[idColumn];
690
+ const id = typeof rawId === 'string' && rawId ? rawId : randomId();
691
+
692
+ const row = { ...raw, [idColumn]: id } as Record<string, unknown>;
693
+
694
+ const payload = sanitizePayload(row, {
695
+ omit: [
696
+ idColumn,
697
+ ...(versionColumn ? [versionColumn] : []),
698
+ ...omitColumns,
699
+ ],
700
+ });
701
+
702
+ operations.push({
703
+ table: table,
704
+ row_id: id,
705
+ op: 'upsert',
706
+ payload,
707
+ base_version: null,
708
+ });
709
+
710
+ localMutations.push({ table, rowId: id, op: 'upsert' });
711
+ return id;
712
+ },
713
+
714
+ async insertMany(rows) {
715
+ const ids: string[] = [];
716
+ const toUpsert: Record<string, unknown>[] = [];
717
+
718
+ for (const values of rows) {
719
+ const raw = isRecord(values) ? values : {};
720
+ const rawId = raw[idColumn];
721
+ const id = typeof rawId === 'string' && rawId ? rawId : randomId();
722
+ ids.push(id);
723
+ toUpsert.push({ ...raw, [idColumn]: id });
724
+ }
725
+
726
+ for (let i = 0; i < toUpsert.length; i++) {
727
+ const row = toUpsert[i]!;
728
+ const id = ids[i]!;
729
+ const payload = sanitizePayload(row, {
730
+ omit: [
731
+ idColumn,
732
+ ...(versionColumn ? [versionColumn] : []),
733
+ ...omitColumns,
734
+ ],
735
+ });
736
+
737
+ operations.push({
738
+ table: table,
739
+ row_id: id,
740
+ op: 'upsert',
741
+ payload,
742
+ base_version: null,
743
+ });
744
+
745
+ localMutations.push({ table, rowId: id, op: 'upsert' });
746
+ }
747
+
748
+ return ids;
749
+ },
750
+
751
+ async update(id, patch, opts) {
752
+ const rawPatch = isRecord(patch) ? patch : {};
753
+ const sanitized = sanitizePayload(rawPatch, {
754
+ omit: [
755
+ idColumn,
756
+ ...(versionColumn ? [versionColumn] : []),
757
+ ...omitColumns,
758
+ ],
759
+ });
760
+
761
+ const hasExplicitBaseVersion = !!opts && hasOwn(opts, 'baseVersion');
762
+ const baseVersion = hasExplicitBaseVersion
763
+ ? (opts!.baseVersion ?? null)
764
+ : versionColumn && config.readBaseVersion
765
+ ? await config.readBaseVersion({
766
+ table,
767
+ rowId: id,
768
+ idColumn,
769
+ versionColumn,
770
+ })
771
+ : null;
772
+
773
+ operations.push({
774
+ table: table,
775
+ row_id: id,
776
+ op: 'upsert',
777
+ payload: sanitized,
778
+ base_version: coerceBaseVersion(baseVersion),
779
+ });
780
+
781
+ localMutations.push({ table, rowId: id, op: 'upsert' });
782
+ },
783
+
784
+ async delete(id, opts) {
785
+ const hasExplicitBaseVersion = !!opts && hasOwn(opts, 'baseVersion');
786
+ const baseVersion = hasExplicitBaseVersion
787
+ ? (opts!.baseVersion ?? null)
788
+ : versionColumn && config.readBaseVersion
789
+ ? await config.readBaseVersion({
790
+ table,
791
+ rowId: id,
792
+ idColumn,
793
+ versionColumn,
794
+ })
795
+ : null;
796
+
797
+ operations.push({
798
+ table: table,
799
+ row_id: id,
800
+ op: 'delete',
801
+ payload: null,
802
+ base_version: coerceBaseVersion(baseVersion),
803
+ });
804
+
805
+ localMutations.push({ table, rowId: id, op: 'delete' });
806
+ },
807
+
808
+ async upsert(id, patch, opts) {
809
+ await tableApi.update(id, patch, opts);
810
+ },
811
+ };
812
+
813
+ txTableCache.set(table, tableApi);
814
+ return tableApi;
815
+ };
816
+
817
+ const txProxy = new Proxy(
818
+ {},
819
+ {
820
+ get(_target, prop) {
821
+ if (prop === 'then') return undefined;
822
+ if (typeof prop !== 'string') return undefined;
823
+ return makeTxTable(prop);
824
+ },
825
+ }
826
+ ) as MutationsTx<DB>;
827
+
828
+ const result = await fn(txProxy);
829
+
830
+ if (operations.length === 0) {
831
+ throw new Error('No mutations were enqueued');
832
+ }
833
+
834
+ const commitId = randomId();
835
+ const clientCommitId = randomId();
836
+
837
+ const request: SyncPushRequest = {
838
+ clientId: config.clientId,
839
+ clientCommitId,
840
+ operations,
841
+ schemaVersion: config.schemaVersion ?? 1,
842
+ };
843
+
844
+ const plugins = config.plugins ?? [];
845
+ // Sort plugins by priority (lower numbers first, default 50)
846
+ const sortedPlugins = [...plugins].sort(
847
+ (a, b) => (a.priority ?? 50) - (b.priority ?? 50)
848
+ );
849
+ const ctx: SyncClientPluginContext = {
850
+ actorId: config.actorId ?? 'unknown',
851
+ clientId: config.clientId,
852
+ };
853
+
854
+ let requestToSend = request;
855
+ if (sortedPlugins.length > 0) {
856
+ requestToSend = clonePushRequest(request);
857
+ for (const plugin of sortedPlugins) {
858
+ if (!plugin.beforePush) continue;
859
+ requestToSend = await plugin.beforePush(ctx, requestToSend);
860
+ }
861
+ }
862
+
863
+ const combined = await config.transport.sync({
864
+ clientId: requestToSend.clientId,
865
+ push: {
866
+ clientCommitId: requestToSend.clientCommitId,
867
+ operations: requestToSend.operations,
868
+ schemaVersion: requestToSend.schemaVersion,
869
+ },
870
+ });
871
+ if (!combined.push) {
872
+ throw new Error('Server returned no push response');
873
+ }
874
+ const rawResponse = combined.push;
875
+
876
+ let response = rawResponse;
877
+ if (sortedPlugins.length > 0) {
878
+ // Run afterPush in reverse priority order (higher numbers first)
879
+ for (const plugin of [...sortedPlugins].reverse()) {
880
+ if (!plugin.afterPush) continue;
881
+ response = await plugin.afterPush(ctx, {
882
+ request: requestToSend,
883
+ response,
884
+ });
885
+ }
886
+ }
887
+
888
+ if (response.status !== 'applied' && response.status !== 'cached') {
889
+ const conflictCount = response.results.filter(
890
+ (r) => r.status === 'conflict'
891
+ ).length;
892
+ const errorCount = response.results.filter(
893
+ (r) => r.status === 'error'
894
+ ).length;
895
+ throw new Error(
896
+ `Push rejected (${conflictCount} conflicts, ${errorCount} errors)`
897
+ );
898
+ }
899
+
900
+ return {
901
+ result,
902
+ receipt: { commitId, clientCommitId },
903
+ meta: { operations, localMutations, response },
904
+ };
905
+ };
906
+ }
907
+
908
+ export function createPushMutations<DB = AnyDb>(
909
+ config: PushCommitConfig
910
+ ): MutationsApi<DB, undefined> {
911
+ return createMutationsApi(createPushCommit(config));
912
+ }