@appurist/offlinedb 1.0.0

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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@appurist/offlinedb",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Browser-first offline table client for Neon Auth and Postgres RLS.",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "docs",
12
+ "scripts",
13
+ "offlinedb.schema.json",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "db:print-sql": "node ./scripts/print-schema-sql.mjs",
18
+ "db:apply": "node ./scripts/apply-schema.mjs",
19
+ "test": "node --test --test-isolation=none"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Appurist Software Inc. <dev@appurist.com>",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/appurist/offlinedb.git"
26
+ },
27
+ "homepage": "https://github.com/appurist/offlinedb#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/appurist/offlinedb/issues"
30
+ },
31
+ "keywords": [
32
+ "offline",
33
+ "indexeddb",
34
+ "neon",
35
+ "postgres",
36
+ "rls",
37
+ "sync"
38
+ ]
39
+ }
@@ -0,0 +1,54 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ import { createOfflinedbInstallSql, createSyncedTableSql, defineTable } from "../src/index.js";
6
+
7
+ const databaseUrl = process.env.DATABASE_URL;
8
+ if (!databaseUrl) {
9
+ throw new Error("DATABASE_URL is required.");
10
+ }
11
+
12
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const configPath = process.argv[2]
14
+ ? resolve(process.cwd(), process.argv[2])
15
+ : resolve(root, "offlinedb.schema.json");
16
+ const config = JSON.parse(await readFile(configPath, "utf8"));
17
+
18
+ const sqlParts = [
19
+ createOfflinedbInstallSql({
20
+ schema: config.metadataSchema ?? "offlinedb"
21
+ })
22
+ ];
23
+
24
+ for (const tableConfig of config.tables ?? []) {
25
+ sqlParts.push(`
26
+ CREATE TABLE IF NOT EXISTS "${config.schema ?? "public"}"."${tableConfig.name}" (
27
+ "${tableConfig.primaryKey ?? "id"}" TEXT PRIMARY KEY
28
+ );`.trim());
29
+ sqlParts.push(createSyncedTableSql({
30
+ schema: config.schema ?? "public",
31
+ metadataSchema: config.metadataSchema ?? "offlinedb",
32
+ table: defineTable(tableConfig)
33
+ }));
34
+ }
35
+
36
+ const sql = `${sqlParts.join("\n\n")}\n`;
37
+
38
+ await new Promise((resolvePromise, rejectPromise) => {
39
+ const child = spawn("psql", [databaseUrl, "-v", "ON_ERROR_STOP=1"], {
40
+ stdio: ["pipe", "inherit", "inherit"]
41
+ });
42
+ child.stdin.end(sql);
43
+ child.on("error", rejectPromise);
44
+ child.on("exit", (code) => {
45
+ if (code === 0) {
46
+ resolvePromise();
47
+ return;
48
+ }
49
+
50
+ rejectPromise(new Error(`psql exited with code ${code}`));
51
+ });
52
+ });
53
+
54
+ console.log(`Applied offlinedb schema from ${configPath}`);
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+ import { createOfflinedbInstallSql, createSyncedTableSql, defineTable } from "../src/index.js";
5
+
6
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
7
+ const configPath = process.argv[2]
8
+ ? resolve(process.cwd(), process.argv[2])
9
+ : resolve(root, "offlinedb.schema.json");
10
+ const config = JSON.parse(await readFile(configPath, "utf8"));
11
+
12
+ const sqlParts = [
13
+ createOfflinedbInstallSql({
14
+ schema: config.metadataSchema ?? "offlinedb"
15
+ })
16
+ ];
17
+
18
+ for (const tableConfig of config.tables ?? []) {
19
+ sqlParts.push(`
20
+ CREATE TABLE IF NOT EXISTS "${config.schema ?? "public"}"."${tableConfig.name}" (
21
+ "${tableConfig.primaryKey ?? "id"}" TEXT PRIMARY KEY
22
+ );`.trim());
23
+ sqlParts.push(createSyncedTableSql({
24
+ schema: config.schema ?? "public",
25
+ metadataSchema: config.metadataSchema ?? "offlinedb",
26
+ table: defineTable(tableConfig)
27
+ }));
28
+ }
29
+
30
+ process.stdout.write(`${sqlParts.join("\n\n")}\n`);
package/src/client.js ADDED
@@ -0,0 +1,587 @@
1
+ import { defineTable } from "./schema.js";
2
+
3
+ /**
4
+ * @typedef {{
5
+ * localId: string,
6
+ * globalId: number,
7
+ * clientSequence: number,
8
+ * table: string,
9
+ * key: string,
10
+ * baseRevision: number,
11
+ * values: Record<string, unknown>,
12
+ * occurredAt: string,
13
+ * metadata?: Record<string, unknown>
14
+ * }} PendingMutation
15
+ */
16
+
17
+ /**
18
+ * @typedef {{
19
+ * table: string,
20
+ * key: string,
21
+ * row: Record<string, unknown> | null
22
+ * }} ChangeRecord
23
+ */
24
+
25
+ /**
26
+ * @typedef {{
27
+ * accepted: Array<{localId: string, globalId: number, row?: Record<string, unknown> | null}>,
28
+ * conflicts: Array<{localId: string, code: "revision_conflict", serverRow: Record<string, unknown> | null}>,
29
+ * changes: ChangeRecord[],
30
+ * lastGlobalId: number
31
+ * }} SyncResult
32
+ */
33
+
34
+ export class OfflineDbClient {
35
+ /**
36
+ * @param {{
37
+ * persistence?: PersistenceLayer | "indexeddb" | "localstorage" | "memory",
38
+ * transport: {sync: (request: {mutations: PendingMutation[], tables?: string[], lastGlobalId: number}) => Promise<SyncResult>},
39
+ * tables: ReturnType<typeof defineTable>[],
40
+ * databaseName?: string,
41
+ * indexedDb?: IDBFactory,
42
+ * localStorage?: StorageLike,
43
+ * now?: () => Date,
44
+ * idFactory?: () => string
45
+ * }} options
46
+ */
47
+ constructor(options) {
48
+ this.persistence = options.persistence;
49
+ this.transport = options.transport;
50
+ this.tables = new Map(options.tables.map((table) => [table.name, table]));
51
+ this.now = options.now ?? (() => new Date());
52
+ this.idFactory =
53
+ options.idFactory ??
54
+ (() => {
55
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
56
+ return crypto.randomUUID();
57
+ }
58
+
59
+ return `mutation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
65
+ */
66
+ static async open(options) {
67
+ const persistence = resolvePersistence(options);
68
+ await persistence.initialize();
69
+ return new OfflineDbClient({
70
+ ...options,
71
+ persistence
72
+ });
73
+ }
74
+
75
+ /**
76
+ * @param {string} table
77
+ * @param {string} key
78
+ */
79
+ async get(table, key) {
80
+ this.#requireTable(table);
81
+ return this.persistence.getRow(table, key);
82
+ }
83
+
84
+ /**
85
+ * @param {string} table
86
+ */
87
+ async list(table) {
88
+ this.#requireTable(table);
89
+ return this.persistence.listRows(table);
90
+ }
91
+
92
+ /**
93
+ * @param {{table: string, key: string, values: Record<string, unknown>, metadata?: Record<string, unknown>}} args
94
+ */
95
+ async mutate(args) {
96
+ const table = this.#requireTable(args.table);
97
+ const current = await this.persistence.getRow(table.name, args.key);
98
+ const revisionColumn = table.revisionColumn;
99
+ const updatedAtColumn = table.updatedAtColumn;
100
+ const localIdColumn = table.localIdColumn;
101
+ const globalIdColumn = table.globalIdColumn;
102
+ const nowIso = this.now().toISOString();
103
+ const baseRevision = Number(current?.[revisionColumn] ?? 0);
104
+ const localId = this.idFactory();
105
+ const mutation = {
106
+ localId,
107
+ globalId: 0,
108
+ clientSequence: await this.persistence.nextClientSequence(),
109
+ table: table.name,
110
+ key: args.key,
111
+ baseRevision,
112
+ values: {
113
+ ...(current ?? {}),
114
+ ...args.values,
115
+ [table.primaryKey]: args.key,
116
+ [revisionColumn]: baseRevision + 1,
117
+ [updatedAtColumn]: nowIso,
118
+ [localIdColumn]: localId,
119
+ [globalIdColumn]: 0
120
+ },
121
+ occurredAt: nowIso,
122
+ metadata: args.metadata
123
+ };
124
+
125
+ await this.persistence.saveRow(table.name, args.key, mutation.values);
126
+ await this.persistence.appendPendingMutation(mutation);
127
+ return mutation;
128
+ }
129
+
130
+ /**
131
+ * @param {{tables?: string[]}=} options
132
+ */
133
+ async sync(options = {}) {
134
+ const tableNames = this.#resolveTables(options.tables);
135
+ const [mutations, lastGlobalId] = await Promise.all([
136
+ this.persistence.getPendingMutations(),
137
+ this.persistence.getLastGlobalId()
138
+ ]);
139
+ const result = await this.transport.sync({
140
+ mutations,
141
+ tables: tableNames,
142
+ lastGlobalId
143
+ });
144
+
145
+ await this.persistence.acknowledgeMutations(result.accepted.map((entry) => entry.localId));
146
+
147
+ for (const accepted of result.accepted) {
148
+ const mutation = mutations.find((entry) => entry.localId === accepted.localId);
149
+ if (!mutation) {
150
+ continue;
151
+ }
152
+
153
+ if (accepted.row) {
154
+ await this.persistence.saveRow(mutation.table, mutation.key, accepted.row);
155
+ continue;
156
+ }
157
+
158
+ const current = await this.persistence.getRow(mutation.table, mutation.key);
159
+ if (!current) {
160
+ continue;
161
+ }
162
+
163
+ const table = this.#requireTable(mutation.table);
164
+ if (current[table.localIdColumn] !== accepted.localId) {
165
+ continue;
166
+ }
167
+
168
+ await this.persistence.saveRow(mutation.table, mutation.key, {
169
+ ...current,
170
+ [table.localIdColumn]: null,
171
+ [table.globalIdColumn]: accepted.globalId
172
+ });
173
+ }
174
+
175
+ for (const conflict of result.conflicts) {
176
+ const mutation = mutations.find((entry) => entry.localId === conflict.localId);
177
+ if (!mutation) {
178
+ continue;
179
+ }
180
+
181
+ await this.persistence.acknowledgeMutations([conflict.localId]);
182
+ if (conflict.serverRow) {
183
+ await this.persistence.saveRow(mutation.table, mutation.key, conflict.serverRow);
184
+ }
185
+ }
186
+
187
+ for (const change of result.changes) {
188
+ this.#requireTable(change.table);
189
+ if (change.row == null) {
190
+ await this.persistence.deleteRow(change.table, change.key);
191
+ continue;
192
+ }
193
+
194
+ await this.persistence.saveRow(change.table, change.key, change.row);
195
+ }
196
+
197
+ await this.persistence.setLastGlobalId(result.lastGlobalId);
198
+ return result;
199
+ }
200
+
201
+ async exportState() {
202
+ return this.persistence.exportState();
203
+ }
204
+
205
+ #requireTable(name) {
206
+ const table = this.tables.get(name);
207
+ if (!table) {
208
+ throw new Error(`Unknown table: ${name}`);
209
+ }
210
+
211
+ return table;
212
+ }
213
+
214
+ #resolveTables(tableNames) {
215
+ if (!tableNames || tableNames.length === 0) {
216
+ return [...this.tables.keys()];
217
+ }
218
+
219
+ return tableNames.map((name) => this.#requireTable(name).name);
220
+ }
221
+ }
222
+
223
+ export class IndexedDbPersistence {
224
+ /**
225
+ * @param {{databaseName?: string, indexedDb?: IDBFactory}=} options
226
+ */
227
+ constructor(options = {}) {
228
+ this.databaseName = options.databaseName ?? "offlinedb";
229
+ this.indexedDb = options.indexedDb ?? globalThis.indexedDB;
230
+ this.memory = new InMemoryPersistence();
231
+ }
232
+
233
+ async initialize() {
234
+ if (!this.indexedDb) {
235
+ throw new Error("IndexedDB persistence requires indexedDB to be available or injected.");
236
+ }
237
+
238
+ const snapshot = await this.#readSnapshot();
239
+ if (snapshot) {
240
+ await this.memory.importState(snapshot);
241
+ }
242
+ }
243
+
244
+ async nextClientSequence() {
245
+ const value = await this.memory.nextClientSequence();
246
+ await this.#persist();
247
+ return value;
248
+ }
249
+
250
+ async getRow(table, key) {
251
+ return this.memory.getRow(table, key);
252
+ }
253
+
254
+ async listRows(table) {
255
+ return this.memory.listRows(table);
256
+ }
257
+
258
+ async saveRow(table, key, row) {
259
+ await this.memory.saveRow(table, key, row);
260
+ await this.#persist();
261
+ }
262
+
263
+ async deleteRow(table, key) {
264
+ await this.memory.deleteRow(table, key);
265
+ await this.#persist();
266
+ }
267
+
268
+ async appendPendingMutation(mutation) {
269
+ await this.memory.appendPendingMutation(mutation);
270
+ await this.#persist();
271
+ }
272
+
273
+ async getPendingMutations() {
274
+ return this.memory.getPendingMutations();
275
+ }
276
+
277
+ async acknowledgeMutations(mutationIds) {
278
+ await this.memory.acknowledgeMutations(mutationIds);
279
+ await this.#persist();
280
+ }
281
+
282
+ async getLastGlobalId() {
283
+ return this.memory.getLastGlobalId();
284
+ }
285
+
286
+ async setLastGlobalId(lastGlobalId) {
287
+ await this.memory.setLastGlobalId(lastGlobalId);
288
+ await this.#persist();
289
+ }
290
+
291
+ async exportState() {
292
+ return this.memory.exportState();
293
+ }
294
+
295
+ async #persist() {
296
+ const db = await this.#openDatabase();
297
+ const snapshot = await this.memory.exportState();
298
+
299
+ await new Promise((resolve, reject) => {
300
+ const tx = db.transaction("state", "readwrite");
301
+ tx.objectStore("state").put(snapshot, "snapshot");
302
+ tx.oncomplete = () => resolve();
303
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed."));
304
+ });
305
+ }
306
+
307
+ async #readSnapshot() {
308
+ const db = await this.#openDatabase();
309
+
310
+ return new Promise((resolve, reject) => {
311
+ const tx = db.transaction("state", "readonly");
312
+ const request = tx.objectStore("state").get("snapshot");
313
+ request.onsuccess = () => resolve(request.result ?? null);
314
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB read failed."));
315
+ });
316
+ }
317
+
318
+ async #openDatabase() {
319
+ return new Promise((resolve, reject) => {
320
+ const request = this.indexedDb.open(this.databaseName, 1);
321
+ request.onupgradeneeded = () => {
322
+ if (!request.result.objectStoreNames.contains("state")) {
323
+ request.result.createObjectStore("state");
324
+ }
325
+ };
326
+ request.onsuccess = () => resolve(request.result);
327
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed."));
328
+ });
329
+ }
330
+ }
331
+
332
+ export class LocalStoragePersistence {
333
+ /**
334
+ * @param {{storageKey?: string, localStorage?: StorageLike}=} options
335
+ */
336
+ constructor(options = {}) {
337
+ this.storageKey = options.storageKey ?? "offlinedb:snapshot";
338
+ this.localStorage = options.localStorage ?? globalThis.localStorage;
339
+ this.memory = new InMemoryPersistence();
340
+ }
341
+
342
+ async initialize() {
343
+ if (!this.localStorage) {
344
+ throw new Error("LocalStorage persistence requires localStorage to be available or injected.");
345
+ }
346
+
347
+ const raw = this.localStorage.getItem(this.storageKey);
348
+ if (!raw) {
349
+ return;
350
+ }
351
+
352
+ await this.memory.importState(JSON.parse(raw));
353
+ }
354
+
355
+ async nextClientSequence() {
356
+ const value = await this.memory.nextClientSequence();
357
+ await this.#persist();
358
+ return value;
359
+ }
360
+
361
+ async getRow(table, key) {
362
+ return this.memory.getRow(table, key);
363
+ }
364
+
365
+ async listRows(table) {
366
+ return this.memory.listRows(table);
367
+ }
368
+
369
+ async saveRow(table, key, row) {
370
+ await this.memory.saveRow(table, key, row);
371
+ await this.#persist();
372
+ }
373
+
374
+ async deleteRow(table, key) {
375
+ await this.memory.deleteRow(table, key);
376
+ await this.#persist();
377
+ }
378
+
379
+ async appendPendingMutation(mutation) {
380
+ await this.memory.appendPendingMutation(mutation);
381
+ await this.#persist();
382
+ }
383
+
384
+ async getPendingMutations() {
385
+ return this.memory.getPendingMutations();
386
+ }
387
+
388
+ async acknowledgeMutations(mutationIds) {
389
+ await this.memory.acknowledgeMutations(mutationIds);
390
+ await this.#persist();
391
+ }
392
+
393
+ async getLastGlobalId() {
394
+ return this.memory.getLastGlobalId();
395
+ }
396
+
397
+ async setLastGlobalId(lastGlobalId) {
398
+ await this.memory.setLastGlobalId(lastGlobalId);
399
+ await this.#persist();
400
+ }
401
+
402
+ async exportState() {
403
+ return this.memory.exportState();
404
+ }
405
+
406
+ async #persist() {
407
+ const snapshot = await this.memory.exportState();
408
+ this.localStorage.setItem(this.storageKey, JSON.stringify(snapshot));
409
+ }
410
+ }
411
+
412
+ export class InMemoryPersistence {
413
+ constructor() {
414
+ this.rows = new Map();
415
+ this.pending = new Map();
416
+ this.lastGlobalId = 0;
417
+ this.clientSequence = 0;
418
+ }
419
+
420
+ async initialize() {}
421
+
422
+ async nextClientSequence() {
423
+ this.clientSequence += 1;
424
+ return this.clientSequence;
425
+ }
426
+
427
+ async getRow(table, key) {
428
+ return this.#bucket(table).get(key) ?? null;
429
+ }
430
+
431
+ async listRows(table) {
432
+ return [...this.#bucket(table).values()];
433
+ }
434
+
435
+ async saveRow(table, key, row) {
436
+ this.#bucket(table).set(key, clone(row));
437
+ }
438
+
439
+ async deleteRow(table, key) {
440
+ this.#bucket(table).delete(key);
441
+ }
442
+
443
+ async appendPendingMutation(mutation) {
444
+ this.pending.set(mutation.localId, clone(mutation));
445
+ }
446
+
447
+ async getPendingMutations() {
448
+ return [...this.pending.values()].sort((left, right) => left.clientSequence - right.clientSequence);
449
+ }
450
+
451
+ async acknowledgeMutations(mutationIds) {
452
+ for (const mutationId of mutationIds) {
453
+ this.pending.delete(mutationId);
454
+ }
455
+ }
456
+
457
+ async getLastGlobalId() {
458
+ return this.lastGlobalId;
459
+ }
460
+
461
+ async setLastGlobalId(lastGlobalId) {
462
+ this.lastGlobalId = Number(lastGlobalId ?? 0);
463
+ }
464
+
465
+ async exportState() {
466
+ return {
467
+ clientSequence: this.clientSequence,
468
+ lastGlobalId: this.lastGlobalId,
469
+ pending: await this.getPendingMutations(),
470
+ rows: [...this.rows.entries()].map(([table, bucket]) => [table, [...bucket.entries()].map(([key, row]) => [key, clone(row)])])
471
+ };
472
+ }
473
+
474
+ async importState(state) {
475
+ this.clientSequence = Number(state?.clientSequence ?? 0);
476
+ this.lastGlobalId = Number(state?.lastGlobalId ?? state?.watermark ?? state?.cursors?.global ?? 0);
477
+ this.pending.clear();
478
+ this.rows.clear();
479
+
480
+ for (const mutation of state?.pending ?? []) {
481
+ this.pending.set(mutation.localId ?? mutation.mutationId, clone(mutation));
482
+ }
483
+
484
+ for (const [table, entries] of state?.rows ?? []) {
485
+ const bucket = this.#bucket(table);
486
+ for (const [key, row] of entries) {
487
+ bucket.set(key, clone(row));
488
+ }
489
+ }
490
+ }
491
+
492
+ #bucket(table) {
493
+ if (!this.rows.has(table)) {
494
+ this.rows.set(table, new Map());
495
+ }
496
+
497
+ return this.rows.get(table);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * @param {ConstructorParameters<typeof OfflineDbClient>[0]} options
503
+ */
504
+ function resolvePersistence(options) {
505
+ if (!options.persistence || options.persistence === "indexeddb") {
506
+ return new IndexedDbPersistence({
507
+ databaseName: options.databaseName ?? "offlinedb",
508
+ indexedDb: options.indexedDb
509
+ });
510
+ }
511
+
512
+ if (options.persistence === "memory") {
513
+ return new InMemoryPersistence();
514
+ }
515
+
516
+ if (options.persistence === "localstorage") {
517
+ return new LocalStoragePersistence({
518
+ storageKey: `${options.databaseName ?? "offlinedb"}:snapshot`,
519
+ localStorage: options.localStorage
520
+ });
521
+ }
522
+
523
+ return options.persistence;
524
+ }
525
+
526
+ /**
527
+ * @typedef {{
528
+ * initialize: () => Promise<void>,
529
+ * nextClientSequence: () => Promise<number>,
530
+ * getRow: (table: string, key: string) => Promise<Record<string, unknown> | null>,
531
+ * listRows: (table: string) => Promise<Record<string, unknown>[]>,
532
+ * saveRow: (table: string, key: string, row: Record<string, unknown>) => Promise<void>,
533
+ * deleteRow: (table: string, key: string) => Promise<void>,
534
+ * appendPendingMutation: (mutation: PendingMutation) => Promise<void>,
535
+ * getPendingMutations: () => Promise<PendingMutation[]>,
536
+ * acknowledgeMutations: (mutationIds: string[]) => Promise<void>,
537
+ * getLastGlobalId: () => Promise<number>,
538
+ * setLastGlobalId: (lastGlobalId: number) => Promise<void>,
539
+ * exportState: () => Promise<unknown>
540
+ * }} PersistenceLayer
541
+ *
542
+ * @typedef {{
543
+ * open: (name: string, version: number) => IDBOpenDBRequest
544
+ * }} IDBFactory
545
+ *
546
+ * @typedef {{
547
+ * getItem: (key: string) => string | null,
548
+ * setItem: (key: string, value: string) => void
549
+ * }} StorageLike
550
+ */
551
+
552
+ /**
553
+ * @param {{baseUrl: string, fetchImpl?: typeof fetch, getAuthToken?: () => Promise<string | null> | string | null, syncPath?: string}} options
554
+ */
555
+ export function createNeonHttpTransport(options) {
556
+ const fetchImpl = options.fetchImpl ?? fetch;
557
+ const syncPath = options.syncPath ?? "rpc/offlinedb_sync";
558
+
559
+ return {
560
+ async sync(request) {
561
+ const token = await options.getAuthToken?.();
562
+ const response = await fetchImpl(joinUrl(options.baseUrl, syncPath), {
563
+ method: "POST",
564
+ headers: {
565
+ "content-type": "application/json",
566
+ ...(token ? { authorization: `Bearer ${token}` } : {})
567
+ },
568
+ body: JSON.stringify(request)
569
+ });
570
+
571
+ if (!response.ok) {
572
+ throw new Error(`Sync failed with status ${response.status}`);
573
+ }
574
+
575
+ return response.json();
576
+ }
577
+ };
578
+ }
579
+
580
+ function joinUrl(baseUrl, path) {
581
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
582
+ return new URL(path, normalizedBase);
583
+ }
584
+
585
+ function clone(value) {
586
+ return value == null ? value : JSON.parse(JSON.stringify(value));
587
+ }