@syncular/client 0.0.1-60

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