korajs 0.3.5 → 0.5.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/dist/index.cjs CHANGED
@@ -30,28 +30,44 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- HybridLogicalClock: () => import_core2.HybridLogicalClock,
34
- KoraError: () => import_core5.KoraError,
35
- MergeEngine: () => import_merge2.MergeEngine,
36
- Store: () => import_store2.Store,
37
- SyncEngine: () => import_sync2.SyncEngine,
38
- WebSocketTransport: () => import_sync2.WebSocketTransport,
33
+ HybridLogicalClock: () => import_core7.HybridLogicalClock,
34
+ KoraError: () => import_core10.KoraError,
35
+ MergeEngine: () => import_merge3.MergeEngine,
36
+ SequenceManager: () => import_store6.SequenceManager,
37
+ Store: () => import_store6.Store,
38
+ SyncEngine: () => import_sync3.SyncEngine,
39
+ TransactionContext: () => import_store7.TransactionContext,
40
+ WebSocketTransport: () => import_sync3.WebSocketTransport,
39
41
  createApp: () => createApp,
40
- createOperation: () => import_core4.createOperation,
41
- defineSchema: () => import_core.defineSchema,
42
- generateUUIDv7: () => import_core3.generateUUIDv7,
43
- t: () => import_core.t
42
+ createOperation: () => import_core9.createOperation,
43
+ decodeAuditExport: () => import_store5.decodeAuditExport,
44
+ defineSchema: () => import_core6.defineSchema,
45
+ exportBackup: () => import_store8.exportBackup,
46
+ generateUUIDv7: () => import_core8.generateUUIDv7,
47
+ migrate: () => import_core6.migrate,
48
+ op: () => import_core11.op,
49
+ readAuditExportManifest: () => import_store5.readAuditExportManifest,
50
+ readBackupManifest: () => import_store8.readBackupManifest,
51
+ restoreBackup: () => import_store8.restoreBackup,
52
+ t: () => import_core6.t,
53
+ verifyAuditExportChecksum: () => import_store5.verifyAuditExportChecksum,
54
+ verifyBackupChecksum: () => import_store8.verifyBackupChecksum
44
55
  });
45
56
  module.exports = __toCommonJS(index_exports);
46
57
 
47
58
  // src/create-app.ts
48
- var import_internal = require("@korajs/core/internal");
59
+ var import_core5 = require("@korajs/core");
60
+ var import_internal5 = require("@korajs/core/internal");
49
61
  var import_devtools = require("@korajs/devtools");
50
- var import_merge = require("@korajs/merge");
51
- var import_store = require("@korajs/store");
52
- var import_sync = require("@korajs/sync");
62
+ var import_merge2 = require("@korajs/merge");
63
+ var import_store4 = require("@korajs/store");
64
+ var import_sync2 = require("@korajs/sync");
53
65
 
54
66
  // src/adapter-resolver.ts
67
+ function importOptionalPeer(specifier) {
68
+ const dynamicImport = new Function("specifier", "return import(specifier)");
69
+ return dynamicImport(specifier);
70
+ }
55
71
  function detectAdapterType() {
56
72
  if (typeof globalThis !== "undefined" && typeof globalThis.__TAURI_INTERNALS__ !== "undefined") {
57
73
  return "tauri-sqlite";
@@ -67,13 +83,10 @@ function detectAdapterType() {
67
83
  }
68
84
  return "better-sqlite3";
69
85
  }
70
- async function createAdapter(type, dbName, workerUrl) {
86
+ async function createAdapter(type, dbName, workerUrl, emitter, workerResponseTimeoutMs, sharedWorkerUrl) {
71
87
  switch (type) {
72
88
  case "tauri-sqlite": {
73
- const { TauriSqliteAdapter } = await import(
74
- /* @vite-ignore */
75
- "@korajs/tauri"
76
- );
89
+ const { TauriSqliteAdapter } = await importOptionalPeer("@korajs/tauri");
77
90
  return new TauriSqliteAdapter({ path: `${dbName}.db` });
78
91
  }
79
92
  case "better-sqlite3": {
@@ -85,11 +98,11 @@ async function createAdapter(type, dbName, workerUrl) {
85
98
  }
86
99
  case "sqlite-wasm": {
87
100
  const { SqliteWasmAdapter } = await import("@korajs/store/sqlite-wasm");
88
- return new SqliteWasmAdapter({ dbName, workerUrl });
101
+ return new SqliteWasmAdapter({ dbName, workerUrl, sharedWorkerUrl, workerResponseTimeoutMs });
89
102
  }
90
103
  case "indexeddb": {
91
104
  const { IndexedDbAdapter } = await import("@korajs/store/indexeddb");
92
- return new IndexedDbAdapter({ dbName, workerUrl });
105
+ return new IndexedDbAdapter({ dbName, workerUrl, emitter, workerResponseTimeoutMs });
93
106
  }
94
107
  default: {
95
108
  const _exhaustive = type;
@@ -98,85 +111,536 @@ async function createAdapter(type, dbName, workerUrl) {
98
111
  }
99
112
  }
100
113
 
101
- // src/merge-aware-sync-store.ts
102
- var MergeAwareSyncStore = class {
103
- constructor(store, mergeEngine, emitter) {
104
- this.store = store;
105
- this.mergeEngine = mergeEngine;
106
- this.emitter = emitter;
114
+ // src/app-not-ready-error.ts
115
+ var import_core = require("@korajs/core");
116
+ var AppNotReadyError = class extends import_core.KoraError {
117
+ constructor(detail) {
118
+ super(detail, "APP_NOT_READY", {
119
+ fix: "Await app.ready, or wrap your UI in <KoraProvider app={app}> before calling useQuery()."
120
+ });
121
+ this.name = "AppNotReadyError";
107
122
  }
108
- store;
109
- mergeEngine;
110
- emitter;
111
- getVersionVector() {
112
- return this.store.getVersionVector();
123
+ };
124
+
125
+ // src/apply-pipeline.ts
126
+ var import_core3 = require("@korajs/core");
127
+ var import_internal2 = require("@korajs/core/internal");
128
+ var import_merge = require("@korajs/merge");
129
+ var import_store = require("@korajs/store");
130
+ var import_internal3 = require("@korajs/store/internal");
131
+
132
+ // src/build-side-effect-entry.ts
133
+ var import_core2 = require("@korajs/core");
134
+ var import_internal = require("@korajs/store/internal");
135
+ async function buildSideEffectEntry(ctx, effect, parentOpId, transactionId, mutationName) {
136
+ const causalDeps = [parentOpId];
137
+ const baseInput = {
138
+ nodeId: ctx.nodeId,
139
+ collection: effect.collection,
140
+ recordId: effect.recordId,
141
+ sequenceNumber: await ctx.allocateSequenceNumber(),
142
+ causalDeps,
143
+ schemaVersion: ctx.schema.version,
144
+ ...transactionId !== void 0 ? { transactionId } : {},
145
+ ...mutationName !== void 0 ? { mutationName } : {}
146
+ };
147
+ if (effect.type === "delete") {
148
+ const operation2 = await (0, import_core2.createOperation)(
149
+ {
150
+ ...baseInput,
151
+ type: "delete",
152
+ data: null,
153
+ previousData: effect.previousData
154
+ },
155
+ ctx.clock
156
+ );
157
+ ctx.causalTracker?.afterOperation(effect.collection, operation2.id, ctx.inTransaction);
158
+ const version2 = (0, import_internal.serializeRowVersion)(operation2.timestamp);
159
+ const deleteQuery = (0, import_internal.buildSoftDeleteQuery)(
160
+ effect.collection,
161
+ effect.recordId,
162
+ operation2.timestamp.wallTime,
163
+ version2
164
+ );
165
+ const opInsert2 = (0, import_internal.buildInsertQuery)(
166
+ `_kora_ops_${effect.collection}`,
167
+ (0, import_internal.serializeOperation)(operation2)
168
+ );
169
+ return {
170
+ operation: operation2,
171
+ collection: effect.collection,
172
+ commands: [
173
+ { sql: deleteQuery.sql, params: deleteQuery.params },
174
+ { sql: opInsert2.sql, params: opInsert2.params },
175
+ {
176
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
177
+ params: [ctx.nodeId, operation2.sequenceNumber]
178
+ }
179
+ ]
180
+ };
113
181
  }
114
- getNodeId() {
115
- return this.store.getNodeId();
182
+ const operation = await (0, import_core2.createOperation)(
183
+ {
184
+ ...baseInput,
185
+ type: "update",
186
+ data: effect.data,
187
+ previousData: effect.previousData
188
+ },
189
+ ctx.clock
190
+ );
191
+ ctx.causalTracker?.afterOperation(effect.collection, operation.id, ctx.inTransaction);
192
+ const definition = ctx.schema.collections[effect.collection];
193
+ const serializedChanges = effect.data && definition ? (0, import_internal.serializeRecord)(effect.data, definition.fields) : effect.data ?? {};
194
+ const version = (0, import_internal.serializeRowVersion)(operation.timestamp);
195
+ const updateQuery = (0, import_internal.buildUpdateQuery)(effect.collection, effect.recordId, {
196
+ ...serializedChanges,
197
+ _updated_at: operation.timestamp.wallTime,
198
+ _version: version
199
+ });
200
+ const opInsert = (0, import_internal.buildInsertQuery)(
201
+ `_kora_ops_${effect.collection}`,
202
+ (0, import_internal.serializeOperation)(operation)
203
+ );
204
+ return {
205
+ operation,
206
+ collection: effect.collection,
207
+ commands: [
208
+ { sql: updateQuery.sql, params: updateQuery.params },
209
+ { sql: opInsert.sql, params: opInsert.params },
210
+ {
211
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
212
+ params: [ctx.nodeId, operation.sequenceNumber]
213
+ }
214
+ ]
215
+ };
216
+ }
217
+
218
+ // src/apply-pipeline.ts
219
+ var ApplyPipeline = class {
220
+ constructor(deps) {
221
+ this.deps = deps;
222
+ this.relationLookupMap = (0, import_merge.buildMergeRelationLookup)(deps.store.getSchema());
116
223
  }
117
- async getOperationRange(nodeId, fromSeq, toSeq) {
118
- return this.store.getOperationRange(nodeId, fromSeq, toSeq);
224
+ deps;
225
+ relationLookupMap;
226
+ /** Local insert — same persistence path as remote apply side effects. */
227
+ async insert(collection, data) {
228
+ return (0, import_internal3.executeInsert)(this.deps.store.createMutationContext(collection), data);
229
+ }
230
+ /** Local update. */
231
+ async update(collection, id, data) {
232
+ return (0, import_internal3.executeUpdate)(this.deps.store.createMutationContext(collection), id, data);
119
233
  }
120
- async applyRemoteOperation(op) {
121
- if (op.type !== "update" || !op.data || !op.previousData) {
122
- return this.store.applyRemoteOperation(op);
234
+ /**
235
+ * Local delete with merge-package referential integrity (same rules as remote delete).
236
+ */
237
+ async delete(collection, id) {
238
+ const ctx = this.deps.store.createMutationContext(collection);
239
+ const causalDeps = (0, import_internal3.resolveCausalDeps)(ctx);
240
+ const operation = await (0, import_core3.createOperation)(
241
+ {
242
+ nodeId: ctx.nodeId,
243
+ type: "delete",
244
+ collection,
245
+ recordId: id,
246
+ data: null,
247
+ previousData: null,
248
+ sequenceNumber: await ctx.allocateSequenceNumber(),
249
+ causalDeps,
250
+ schemaVersion: ctx.schema.version
251
+ },
252
+ ctx.clock
253
+ );
254
+ ctx.causalTracker?.afterOperation(collection, operation.id, ctx.inTransaction);
255
+ const refCtx = createReferentialMergeContext(this.deps.store);
256
+ const check = await (0, import_merge.checkReferentialIntegrityOnDelete)(
257
+ operation,
258
+ this.deps.store.getSchema(),
259
+ refCtx,
260
+ this.relationLookupMap
261
+ );
262
+ for (const trace of check.traces) {
263
+ this.deps.emitter?.emit({ type: "merge:conflict", trace });
264
+ }
265
+ if (!check.allowed) {
266
+ throw new import_core3.KoraError(
267
+ `Cannot delete record "${id}" from "${collection}": referential restrict policy violated`,
268
+ "REFERENTIAL_INTEGRITY",
269
+ { collection, recordId: id }
270
+ );
271
+ }
272
+ await (0, import_internal3.executeDelete)(ctx, id, {
273
+ skipReferentialEnforcement: true,
274
+ operation
275
+ });
276
+ if (check.sideEffectOps.length > 0) {
277
+ await applySideEffectOps(this.deps.store, check.sideEffectOps, operation.id);
278
+ }
279
+ }
280
+ /**
281
+ * Commit a buffered transaction: merge-package delete enforcement, causal ordering, single DB txn.
282
+ */
283
+ async commitTransaction(batch) {
284
+ const store = this.deps.store;
285
+ const supplemental = [];
286
+ for (const entry of batch.entries) {
287
+ if (entry.operation.type !== "delete") {
288
+ continue;
289
+ }
290
+ const refCtx = createReferentialMergeContext(store);
291
+ const check = await (0, import_merge.checkReferentialIntegrityOnDelete)(
292
+ entry.operation,
293
+ store.getSchema(),
294
+ refCtx,
295
+ this.relationLookupMap
296
+ );
297
+ for (const trace of check.traces) {
298
+ this.deps.emitter?.emit({ type: "merge:conflict", trace });
299
+ }
300
+ if (!check.allowed) {
301
+ throw new import_core3.KoraError(
302
+ `Cannot delete record "${entry.operation.recordId}" from "${entry.collection}": referential restrict policy violated`,
303
+ "REFERENTIAL_INTEGRITY",
304
+ { collection: entry.collection, recordId: entry.operation.recordId }
305
+ );
306
+ }
307
+ for (const effect of check.sideEffectOps) {
308
+ const ctx2 = store.createMutationContext(effect.collection, { inTransaction: true });
309
+ supplemental.push(
310
+ await buildSideEffectEntry(
311
+ ctx2,
312
+ effect,
313
+ entry.operation.id,
314
+ batch.transactionId,
315
+ batch.mutationName
316
+ )
317
+ );
318
+ }
123
319
  }
124
- const schema = this.store.getSchema();
125
- const collectionDef = schema.collections[op.collection];
320
+ const allEntries = [...batch.entries, ...supplemental];
321
+ const sortedOps = (0, import_internal2.topologicalSort)(allEntries.map((e) => e.operation));
322
+ const commandsByOpId = new Map(allEntries.map((e) => [e.operation.id, e.commands]));
323
+ const ctx = store.createMutationContext(
324
+ batch.entries[0]?.collection ?? Object.keys(store.getSchema().collections)[0] ?? "todos",
325
+ { inTransaction: true }
326
+ );
327
+ await ctx.adapter.transaction(async (tx) => {
328
+ for (const op2 of sortedOps) {
329
+ const commands = commandsByOpId.get(op2.id);
330
+ if (!commands) {
331
+ continue;
332
+ }
333
+ for (const cmd of commands) {
334
+ await tx.execute(cmd.sql, cmd.params);
335
+ }
336
+ }
337
+ });
338
+ const affectedCollections = /* @__PURE__ */ new Set();
339
+ for (const entry of allEntries) {
340
+ affectedCollections.add(entry.collection);
341
+ }
342
+ return { operations: sortedOps, affectedCollections };
343
+ }
344
+ async applyRemote(op2) {
345
+ return this.apply(op2, { mode: "remote", schema: this.deps.store.getSchema() });
346
+ }
347
+ async apply(op2, context) {
348
+ if (context.mode === "local") {
349
+ return this.deps.store.applyRemoteOperation(op2);
350
+ }
351
+ if (op2.type === "delete") {
352
+ return this.applyRemoteDelete(op2);
353
+ }
354
+ if (op2.type === "insert" && op2.data) {
355
+ return this.applyRemoteInsert(op2);
356
+ }
357
+ if (op2.type === "update" && op2.data && op2.previousData) {
358
+ return this.applyRemoteUpdate(op2);
359
+ }
360
+ return this.deps.store.applyRemoteOperation(op2);
361
+ }
362
+ async applyRemoteDelete(op2) {
363
+ const blocked = await this.resolveRemoteDeleteVsLocalUpdate(op2);
364
+ if (blocked) {
365
+ return "skipped";
366
+ }
367
+ const refCtx = createReferentialMergeContext(this.deps.store);
368
+ const check = await (0, import_merge.checkReferentialIntegrityOnDelete)(
369
+ op2,
370
+ this.deps.store.getSchema(),
371
+ refCtx,
372
+ this.relationLookupMap
373
+ );
374
+ for (const trace of check.traces) {
375
+ this.deps.emitter?.emit({ type: "merge:conflict", trace });
376
+ }
377
+ if (!check.allowed) {
378
+ return "rejected";
379
+ }
380
+ const result = await this.deps.store.applyRemoteOperation(op2);
381
+ if (result !== "applied") {
382
+ return result;
383
+ }
384
+ if (check.sideEffectOps.length > 0) {
385
+ await applySideEffectOps(this.deps.store, check.sideEffectOps, op2.id);
386
+ }
387
+ return "applied";
388
+ }
389
+ /**
390
+ * When a local update is newer than a remote delete, keep the record alive.
391
+ */
392
+ async resolveRemoteDeleteVsLocalUpdate(op2) {
393
+ const collectionDef = this.deps.store.getSchema().collections[op2.collection];
394
+ if (!collectionDef) {
395
+ return false;
396
+ }
397
+ const localOp = await this.deps.store.getLatestLocalOperationForRecord(
398
+ op2.collection,
399
+ op2.recordId
400
+ );
401
+ if (!localOp || localOp.type !== "update") {
402
+ return false;
403
+ }
404
+ const baseState = localOp.previousData ?? {};
405
+ const mergeResult = await this.deps.mergeEngine.merge({
406
+ local: localOp,
407
+ remote: op2,
408
+ baseState,
409
+ collectionDef
410
+ });
411
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
412
+ return mergeResult.appliedOperation === "local";
413
+ }
414
+ async applyRemoteUpdate(op2) {
415
+ const schema = this.deps.store.getSchema();
416
+ const collectionDef = schema.collections[op2.collection];
126
417
  if (!collectionDef) {
127
- return this.store.applyRemoteOperation(op);
418
+ return this.deps.store.applyRemoteOperation(op2);
128
419
  }
129
- const accessor = this.store.collection(op.collection);
130
- const currentRecord = await accessor.findById(op.recordId);
420
+ const accessor = this.deps.store.collection(op2.collection);
421
+ const currentRecord = await accessor.findById(op2.recordId);
131
422
  if (!currentRecord) {
132
- return this.store.applyRemoteOperation(op);
423
+ const tombstoneResult = await this.applyRemoteUpdateOnDeletedRow(op2, collectionDef);
424
+ if (tombstoneResult !== null) {
425
+ return tombstoneResult;
426
+ }
427
+ return this.deps.store.applyRemoteOperation(op2);
133
428
  }
134
429
  let hasConflict = false;
135
- for (const field of Object.keys(op.data)) {
136
- const expectedBase = op.previousData[field];
430
+ for (const field of Object.keys(op2.data ?? {})) {
431
+ const fieldDef = collectionDef.fields[field];
432
+ const expectedBase = op2.previousData?.[field];
137
433
  const currentLocal = currentRecord[field];
434
+ if (fieldDef?.kind === "richtext") {
435
+ if (!(0, import_store.richtextStatesEqual)(currentLocal, expectedBase)) {
436
+ hasConflict = true;
437
+ }
438
+ continue;
439
+ }
138
440
  if (!deepEqual(expectedBase, currentLocal)) {
139
441
  hasConflict = true;
140
442
  break;
141
443
  }
142
444
  }
143
445
  if (!hasConflict) {
144
- return this.store.applyRemoteOperation(op);
446
+ return this.deps.store.applyRemoteOperation(op2);
145
447
  }
146
- this.emitter?.emit({
147
- type: "merge:started",
148
- operationA: op,
149
- operationB: op
448
+ return this.applyMergedUpdate(op2, collectionDef, currentRecord, op2.previousData ?? {});
449
+ }
450
+ /**
451
+ * Remote update vs local soft-delete: merge before materializing to avoid zombie rows.
452
+ */
453
+ async applyRemoteUpdateOnDeletedRow(op2, collectionDef) {
454
+ const snapshot = await this.deps.store.findMaterializedRow(op2.collection, op2.recordId);
455
+ if (!snapshot?.deleted) {
456
+ return null;
457
+ }
458
+ const localOp = await this.deps.store.getLatestLocalOperationForRecord(
459
+ op2.collection,
460
+ op2.recordId
461
+ );
462
+ if (!localOp || localOp.type !== "delete") {
463
+ return null;
464
+ }
465
+ const mergeResult = await this.deps.mergeEngine.merge({
466
+ local: localOp,
467
+ remote: op2,
468
+ baseState: op2.previousData ?? {},
469
+ collectionDef
470
+ });
471
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
472
+ if (mergeResult.appliedOperation === "local") {
473
+ return "skipped";
474
+ }
475
+ const mergedOp = {
476
+ ...op2,
477
+ data: { ...mergeResult.mergedData },
478
+ timestamp: maxTimestamp(op2.timestamp, localOp.timestamp)
479
+ };
480
+ const applyResult = await this.deps.store.applyRemoteOperation(mergedOp, {
481
+ reactivateIfDeleted: true
150
482
  });
151
- const baseState = { ...op.previousData };
483
+ if (applyResult === "applied" && mergeResult.sideEffects.length > 0) {
484
+ await applySideEffectOps(this.deps.store, mergeResult.sideEffects, op2.id);
485
+ }
486
+ return applyResult;
487
+ }
488
+ async applyMergedUpdate(op2, collectionDef, currentRecord, baseState, applyOptions) {
489
+ const localTimestamp = await resolveLocalTimestamp(
490
+ this.deps.store,
491
+ op2.collection,
492
+ op2.recordId,
493
+ currentRecord,
494
+ this.deps.store.getNodeId()
495
+ );
152
496
  const localOp = {
153
- ...op,
154
- // The "local" operation's data is the diff between base and current local state
155
- data: buildLocalDiff(op.previousData, currentRecord, Object.keys(op.data)),
156
- previousData: op.previousData,
157
- nodeId: this.store.getNodeId()
497
+ ...op2,
498
+ data: buildLocalDiff(baseState, currentRecord, Object.keys(op2.data ?? {})),
499
+ previousData: op2.previousData,
500
+ nodeId: this.deps.store.getNodeId(),
501
+ timestamp: localTimestamp
158
502
  };
159
- const input = {
503
+ const constraintContext = createConstraintContext(this.deps.store);
504
+ const mergeResult = await this.deps.mergeEngine.merge(
505
+ {
506
+ local: localOp,
507
+ remote: op2,
508
+ baseState,
509
+ collectionDef
510
+ },
511
+ constraintContext
512
+ );
513
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
514
+ const mergedOp = {
515
+ ...op2,
516
+ data: mergeResult.mergedData,
517
+ timestamp: maxTimestamp(op2.timestamp, localTimestamp)
518
+ };
519
+ const applyResult = await this.deps.store.applyRemoteOperation(mergedOp, applyOptions);
520
+ if (applyResult === "applied" && mergeResult.sideEffects.length > 0) {
521
+ await applySideEffectOps(this.deps.store, mergeResult.sideEffects, op2.id);
522
+ }
523
+ return applyResult;
524
+ }
525
+ async applyRemoteInsert(op2) {
526
+ const collectionDef = this.deps.store.getSchema().collections[op2.collection];
527
+ if (!collectionDef || !op2.data) {
528
+ return this.deps.store.applyRemoteOperation(op2);
529
+ }
530
+ const snapshot = await this.deps.store.findMaterializedRow(op2.collection, op2.recordId);
531
+ if (!snapshot || snapshot.deleted) {
532
+ return this.deps.store.applyRemoteOperation(op2);
533
+ }
534
+ const localOp = await resolveLocalOpForRecord(
535
+ this.deps.store,
536
+ op2.collection,
537
+ op2.recordId,
538
+ snapshot.record
539
+ );
540
+ const mergeResult = await this.deps.mergeEngine.merge({
160
541
  local: localOp,
161
- remote: op,
162
- baseState,
542
+ remote: op2,
543
+ baseState: {},
163
544
  collectionDef
545
+ });
546
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
547
+ const mergedOp = {
548
+ ...op2,
549
+ data: mergeResult.mergedData,
550
+ timestamp: maxTimestamp(op2.timestamp, localOp.timestamp)
164
551
  };
165
- const result = this.mergeEngine.mergeFields(input);
166
- for (const trace of result.traces) {
167
- this.emitter?.emit({ type: "merge:conflict", trace });
552
+ return this.deps.store.applyRemoteOperation(mergedOp);
553
+ }
554
+ emitMergeLifecycle(remote, local, mergeResult) {
555
+ this.deps.emitter?.emit({
556
+ type: "merge:started",
557
+ operationA: remote,
558
+ operationB: local
559
+ });
560
+ const hadMergeConflict = mergeResult.traces.some(
561
+ (t2) => t2.strategy !== "no-conflict-local" && t2.strategy !== "no-conflict-remote" && t2.strategy !== "no-conflict-unchanged"
562
+ );
563
+ if (hadMergeConflict) {
564
+ this.deps.onMergeConflict?.();
168
565
  }
169
- const firstTrace = result.traces[0];
566
+ for (const trace of mergeResult.traces) {
567
+ if (trace.strategy !== "no-conflict-local" && trace.strategy !== "no-conflict-remote" && trace.strategy !== "no-conflict-unchanged") {
568
+ this.deps.emitter?.emit({ type: "merge:conflict", trace });
569
+ }
570
+ }
571
+ const firstTrace = mergeResult.traces[0];
170
572
  if (firstTrace) {
171
- this.emitter?.emit({ type: "merge:completed", trace: firstTrace });
573
+ this.deps.emitter?.emit({ type: "merge:completed", trace: firstTrace });
172
574
  }
173
- const mergedOp = {
174
- ...op,
175
- data: result.mergedData
176
- };
177
- return this.store.applyRemoteOperation(mergedOp);
178
575
  }
179
576
  };
577
+ async function resolveLocalOpForRecord(store, collection, recordId, record) {
578
+ const localLatest = await store.getLatestLocalOperationForRecord(collection, recordId);
579
+ if (localLatest) {
580
+ return localLatest;
581
+ }
582
+ const anyLatest = await store.getLatestOperationForRecord(collection, recordId);
583
+ if (anyLatest) {
584
+ return anyLatest;
585
+ }
586
+ return syntheticInsertFromSnapshot(
587
+ record,
588
+ collection,
589
+ store.getNodeId(),
590
+ store.getSchema().version
591
+ );
592
+ }
593
+ function syntheticInsertFromSnapshot(record, collection, nodeId, schemaVersion) {
594
+ const { id, createdAt, updatedAt, ...fields } = record;
595
+ return {
596
+ id: `synthetic-local-${id}`,
597
+ nodeId,
598
+ type: "insert",
599
+ collection,
600
+ recordId: id,
601
+ data: fields,
602
+ previousData: null,
603
+ timestamp: { wallTime: updatedAt, logical: 0, nodeId },
604
+ sequenceNumber: 0,
605
+ causalDeps: [],
606
+ schemaVersion
607
+ };
608
+ }
609
+ function createConstraintContext(store) {
610
+ return {
611
+ async queryRecords(collection, where) {
612
+ const rows = await store.collection(collection).where(where).exec();
613
+ return rows;
614
+ },
615
+ async countRecords(collection, where) {
616
+ return store.collection(collection).where(where).count();
617
+ }
618
+ };
619
+ }
620
+ function createReferentialMergeContext(store) {
621
+ return {
622
+ async queryRecords(collection, where) {
623
+ const rows = await store.collection(collection).where(where).exec();
624
+ return rows;
625
+ },
626
+ async recordExists(collection, recordId) {
627
+ const row = await store.collection(collection).findById(recordId);
628
+ return row !== null;
629
+ }
630
+ };
631
+ }
632
+ async function applySideEffectOps(store, sideEffects, parentOpId) {
633
+ for (const effect of sideEffects) {
634
+ const ctx = store.createMutationContext(effect.collection, {
635
+ extraCausalDeps: [parentOpId]
636
+ });
637
+ if (effect.type === "delete") {
638
+ await (0, import_internal3.executeDelete)(ctx, effect.recordId, { skipReferentialEnforcement: true });
639
+ } else if (effect.type === "update" && effect.data) {
640
+ await (0, import_internal3.executeUpdate)(ctx, effect.recordId, effect.data);
641
+ }
642
+ }
643
+ }
180
644
  function buildLocalDiff(baseState, currentRecord, fields) {
181
645
  const diff = {};
182
646
  for (const field of fields) {
@@ -184,6 +648,20 @@ function buildLocalDiff(baseState, currentRecord, fields) {
184
648
  }
185
649
  return diff;
186
650
  }
651
+ async function resolveLocalTimestamp(store, collection, recordId, currentRecord, nodeId) {
652
+ const latestLocal = await store.getLatestLocalOperationForRecord(collection, recordId);
653
+ if (latestLocal) {
654
+ return latestLocal.timestamp;
655
+ }
656
+ const updatedAt = currentRecord.updatedAt;
657
+ if (typeof updatedAt === "number") {
658
+ return { wallTime: updatedAt, logical: 0, nodeId };
659
+ }
660
+ return { wallTime: Date.now(), logical: 0, nodeId };
661
+ }
662
+ function maxTimestamp(a, b) {
663
+ return import_core3.HybridLogicalClock.compare(a, b) >= 0 ? a : b;
664
+ }
187
665
  function deepEqual(a, b) {
188
666
  if (a === b) return true;
189
667
  if (a === null || b === null) return false;
@@ -204,30 +682,385 @@ function deepEqual(a, b) {
204
682
  return false;
205
683
  }
206
684
 
685
+ // src/audit-bridge.ts
686
+ var import_store2 = require("@korajs/store");
687
+ var AUDIT_EVENT_TYPES = ["merge:completed", "merge:conflict", "constraint:violated"];
688
+ function isAuditEvent(event) {
689
+ return AUDIT_EVENT_TYPES.includes(event.type);
690
+ }
691
+ function wireAuditPersistence(store, emitter) {
692
+ const unsubscribers = AUDIT_EVENT_TYPES.map(
693
+ (eventType) => emitter.on(eventType, (event) => {
694
+ if (!isAuditEvent(event)) {
695
+ return;
696
+ }
697
+ const trace = (0, import_store2.persistedAuditTraceFromEvent)(event);
698
+ void store.appendAuditTrace(trace).catch(() => {
699
+ });
700
+ })
701
+ );
702
+ return () => {
703
+ for (const unsub of unsubscribers) {
704
+ unsub();
705
+ }
706
+ };
707
+ }
708
+
709
+ // src/merge-aware-sync-store.ts
710
+ var MergeAwareSyncStore = class {
711
+ constructor(store, mergeEngine, emitter, options) {
712
+ this.store = store;
713
+ this.pipeline = new ApplyPipeline({
714
+ store,
715
+ mergeEngine,
716
+ emitter,
717
+ onMergeConflict: options?.onMergeConflict
718
+ });
719
+ }
720
+ store;
721
+ pipeline;
722
+ getVersionVector() {
723
+ return this.store.getVersionVector();
724
+ }
725
+ getNodeId() {
726
+ return this.store.getNodeId();
727
+ }
728
+ async getOperationRange(nodeId, fromSeq, toSeq) {
729
+ return this.store.getOperationRange(nodeId, fromSeq, toSeq);
730
+ }
731
+ async applyRemoteOperation(op2) {
732
+ return this.pipeline.applyRemote(op2);
733
+ }
734
+ };
735
+
736
+ // src/store-queue-storage.ts
737
+ var import_internal4 = require("@korajs/store/internal");
738
+ var StoreQueueStorage = class {
739
+ constructor(adapter) {
740
+ this.adapter = adapter;
741
+ }
742
+ adapter;
743
+ async load() {
744
+ const rows = await this.adapter.query(
745
+ "SELECT id, payload FROM _kora_sync_queue ORDER BY rowid ASC"
746
+ );
747
+ return rows.map((row) => operationFromQueuePayload(row.payload));
748
+ }
749
+ async enqueue(op2) {
750
+ const row = (0, import_internal4.serializeOperation)(op2);
751
+ const payload = { ...row, _collection: op2.collection };
752
+ await this.adapter.execute(
753
+ "INSERT OR REPLACE INTO _kora_sync_queue (id, payload) VALUES (?, ?)",
754
+ [op2.id, JSON.stringify(payload)]
755
+ );
756
+ }
757
+ async dequeue(ids) {
758
+ if (ids.length === 0) return;
759
+ const placeholders = ids.map(() => "?").join(", ");
760
+ await this.adapter.execute(`DELETE FROM _kora_sync_queue WHERE id IN (${placeholders})`, ids);
761
+ }
762
+ async count() {
763
+ const rows = await this.adapter.query(
764
+ "SELECT COUNT(*) as cnt FROM _kora_sync_queue"
765
+ );
766
+ return rows[0]?.cnt ?? 0;
767
+ }
768
+ };
769
+ function operationFromQueuePayload(payload) {
770
+ const parsed = JSON.parse(payload);
771
+ const op2 = (0, import_internal4.deserializeOperation)(parsed);
772
+ return {
773
+ ...op2,
774
+ collection: parsed._collection
775
+ };
776
+ }
777
+
778
+ // src/store-sync-state.ts
779
+ var import_store3 = require("@korajs/store");
780
+ var import_sync = require("@korajs/sync");
781
+ var StoreSyncStatePersistence = class {
782
+ constructor(store, scope) {
783
+ this.store = store;
784
+ this.scope = scope;
785
+ }
786
+ store;
787
+ scope;
788
+ loadLastAckedServerVector() {
789
+ return this.store.loadLastAckedServerVector();
790
+ }
791
+ saveLastAckedServerVector(vector) {
792
+ return this.store.saveLastAckedServerVector(vector);
793
+ }
794
+ mergeServerVectors(a, b) {
795
+ return this.store.mergeServerVectors(a, b);
796
+ }
797
+ async countUnsyncedOperations(serverVector) {
798
+ const ops = await this.getUnsyncedOperations(serverVector);
799
+ return ops.length;
800
+ }
801
+ async getUnsyncedOperations(serverVector) {
802
+ const ops = await this.store.getUnsyncedOperations(serverVector);
803
+ return ops.filter((op2) => (0, import_sync.operationMatchesScope)(op2, this.scope));
804
+ }
805
+ loadDeltaCursor() {
806
+ return this.store.loadDeltaCursor().then((encoded) => (0, import_sync.decodeDeltaCursor)(encoded));
807
+ }
808
+ async saveDeltaCursor(cursor) {
809
+ await this.store.saveDeltaCursor(cursor ? (0, import_sync.encodeDeltaCursor)(cursor) : null);
810
+ }
811
+ };
812
+
813
+ // src/sync-query-bridge.ts
814
+ function queryDescriptorToSyncSubset(descriptor) {
815
+ const where = {};
816
+ for (const [field, value] of Object.entries(descriptor.where)) {
817
+ if (value === null || value === void 0) {
818
+ where[field] = value;
819
+ continue;
820
+ }
821
+ if (typeof value !== "object" || Array.isArray(value)) {
822
+ where[field] = value;
823
+ }
824
+ }
825
+ if (Object.keys(where).length === 0) {
826
+ return null;
827
+ }
828
+ return {
829
+ collection: descriptor.collection,
830
+ where
831
+ };
832
+ }
833
+ function createSyncQuerySubscriptionHook(getSyncEngine) {
834
+ return (descriptor) => {
835
+ const subset = queryDescriptorToSyncSubset(descriptor);
836
+ if (!subset) {
837
+ return () => {
838
+ };
839
+ }
840
+ const engine = getSyncEngine();
841
+ if (!engine) {
842
+ return () => {
843
+ };
844
+ }
845
+ return engine.registerQuerySubset(subset);
846
+ };
847
+ }
848
+
849
+ // src/sync-status-bridge.ts
850
+ var OFFLINE_STATUS = Object.freeze({
851
+ status: "offline",
852
+ pendingOperations: 0,
853
+ lastSyncedAt: null,
854
+ lastSuccessfulPush: null,
855
+ lastSuccessfulPull: null,
856
+ conflicts: 0
857
+ });
858
+ var SYNC_STATUS_EVENT_TYPES = [
859
+ "sync:connected",
860
+ "sync:disconnected",
861
+ "sync:schema-mismatch",
862
+ "sync:auth-failed",
863
+ "sync:sent",
864
+ "sync:received",
865
+ "sync:acknowledged",
866
+ "sync:apply-failed",
867
+ "sync:diagnostics",
868
+ "sync:initial-sync-progress"
869
+ ];
870
+ function createSyncStatusBridge(emitter, getSyncEngine) {
871
+ let currentStatus = OFFLINE_STATUS;
872
+ const listeners = /* @__PURE__ */ new Set();
873
+ const refresh = () => {
874
+ const engine = getSyncEngine();
875
+ const next = engine ? engine.getStatus() : OFFLINE_STATUS;
876
+ const prevSerialized = JSON.stringify(currentStatus);
877
+ const nextSerialized = JSON.stringify(next);
878
+ if (prevSerialized === nextSerialized) {
879
+ return;
880
+ }
881
+ currentStatus = next;
882
+ for (const listener of listeners) {
883
+ listener(currentStatus);
884
+ }
885
+ };
886
+ const unsubs = [];
887
+ for (const type of SYNC_STATUS_EVENT_TYPES) {
888
+ unsubs.push(emitter.on(type, refresh));
889
+ }
890
+ const bridge = {
891
+ get status() {
892
+ return currentStatus;
893
+ },
894
+ subscribe(listener) {
895
+ listeners.add(listener);
896
+ listener(currentStatus);
897
+ return () => {
898
+ listeners.delete(listener);
899
+ };
900
+ },
901
+ refresh,
902
+ destroy() {
903
+ for (const unsub of unsubs) {
904
+ unsub();
905
+ }
906
+ unsubs.length = 0;
907
+ listeners.clear();
908
+ }
909
+ };
910
+ refresh();
911
+ return bridge;
912
+ }
913
+
914
+ // src/validate-config.ts
915
+ var import_core4 = require("@korajs/core");
916
+ function validateCreateAppConfig(config) {
917
+ if (!config.schema) {
918
+ throw new import_core4.SchemaValidationError("createApp requires a schema.", {
919
+ fix: "Pass schema: defineSchema({ version: 1, collections: { ... } })"
920
+ });
921
+ }
922
+ if (config.schema.version < 1) {
923
+ throw new import_core4.SchemaValidationError("Schema version must be at least 1.", {
924
+ version: config.schema.version
925
+ });
926
+ }
927
+ const collectionNames = Object.keys(config.schema.collections);
928
+ if (collectionNames.length === 0) {
929
+ throw new import_core4.SchemaValidationError("Schema must define at least one collection.", {
930
+ fix: "Add entries under collections in defineSchema()."
931
+ });
932
+ }
933
+ if (config.sync) {
934
+ validateSyncUrl(config.sync.url, config.sync.transport ?? "websocket");
935
+ }
936
+ const adapter = config.store?.adapter ?? detectAdapterType();
937
+ const isBrowser = typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined";
938
+ if (isBrowser && (adapter === "sqlite-wasm" || adapter === "indexeddb") && !config.store?.workerUrl) {
939
+ throw new import_core4.KoraError(
940
+ "Browser storage requires store.workerUrl pointing to the SQLite WASM worker script.",
941
+ "MISSING_WORKER_URL",
942
+ {
943
+ adapter,
944
+ fix: 'Add store: { workerUrl: "/sqlite-wasm-worker.js" } (see create-kora-app templates).'
945
+ }
946
+ );
947
+ }
948
+ }
949
+ function validateSyncUrl(url, transport) {
950
+ if (!url || url.trim().length === 0) {
951
+ throw new import_core4.KoraError("sync.url is required when sync is configured.", "INVALID_SYNC_URL", {
952
+ fix: 'Pass sync: { url: "wss://your-server/kora" }.'
953
+ });
954
+ }
955
+ try {
956
+ const parsed = new URL(url);
957
+ if (transport === "http") {
958
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
959
+ throw new Error("bad protocol");
960
+ }
961
+ } else if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
962
+ throw new Error("bad protocol");
963
+ }
964
+ } catch {
965
+ throw new import_core4.KoraError(
966
+ `Invalid sync URL "${url}" for transport "${transport}".`,
967
+ "INVALID_SYNC_URL",
968
+ {
969
+ url,
970
+ transport,
971
+ fix: transport === "http" ? "Use an absolute http:// or https:// URL." : "Use an absolute ws:// or wss:// URL."
972
+ }
973
+ );
974
+ }
975
+ }
976
+
207
977
  // src/create-app.ts
208
978
  function createApp(config) {
209
- const emitter = new import_internal.SimpleEventEmitter();
210
- const mergeEngine = new import_merge.MergeEngine();
979
+ validateCreateAppConfig(config);
980
+ const emitter = new import_internal5.SimpleEventEmitter();
981
+ const mergeEngine = new import_merge2.MergeEngine();
982
+ if (config.onSyncEvent) {
983
+ const handler = config.onSyncEvent;
984
+ const syncTypes = [
985
+ "sync:connected",
986
+ "sync:disconnected",
987
+ "sync:schema-mismatch",
988
+ "sync:auth-failed",
989
+ "sync:sent",
990
+ "sync:received",
991
+ "sync:acknowledged",
992
+ "sync:apply-failed",
993
+ "sync:diagnostics",
994
+ "sync:bandwidth",
995
+ "sync:initial-sync-progress"
996
+ ];
997
+ for (const type of syncTypes) {
998
+ emitter.on(type, (event) => {
999
+ handler(event);
1000
+ });
1001
+ }
1002
+ }
211
1003
  let store = null;
212
1004
  let syncEngine = null;
213
1005
  let unsubscribeSync = null;
1006
+ let unsubscribeAudit = null;
214
1007
  let reconnectionManager = null;
215
1008
  let connectionMonitor = null;
216
1009
  let instrumenter = null;
217
1010
  let intentionalDisconnect = false;
218
1011
  let qualityInterval = null;
1012
+ let syncStatusBridge = null;
1013
+ let destroyDevtoolsOverlay = null;
219
1014
  if (config.devtools) {
220
1015
  instrumenter = new import_devtools.Instrumenter(emitter, {
221
1016
  bridgeEnabled: typeof globalThis !== "undefined" && "window" in globalThis
222
1017
  });
1018
+ if (typeof globalThis !== "undefined" && "window" in globalThis) {
1019
+ void import("@korajs/devtools/overlay").then(({ mountKoraDevtoolsOverlay }) => {
1020
+ if (instrumenter) {
1021
+ destroyDevtoolsOverlay = mountKoraDevtoolsOverlay(instrumenter);
1022
+ }
1023
+ }).catch(() => {
1024
+ });
1025
+ }
223
1026
  }
224
1027
  const ready = initializeAsync(config, emitter, mergeEngine).then((result) => {
225
1028
  store = result.store;
226
1029
  syncEngine = result.syncEngine;
227
1030
  unsubscribeSync = result.unsubscribeSync;
1031
+ unsubscribeAudit = result.unsubscribeAudit;
1032
+ const authBinding = result.authBinding;
1033
+ if (config.sync) {
1034
+ syncStatusBridge = createSyncStatusBridge(emitter, () => syncEngine);
1035
+ syncStatusBridge.refresh();
1036
+ }
1037
+ if (config.sync && syncEngine && authBinding?.subscribe) {
1038
+ authBinding.subscribe(() => {
1039
+ const engine = syncEngine;
1040
+ if (!engine) {
1041
+ return;
1042
+ }
1043
+ void (async () => {
1044
+ const headers = await authBinding.auth();
1045
+ if (!headers.token) {
1046
+ await engine.stop();
1047
+ return;
1048
+ }
1049
+ if (authBinding.resolveScopeMap) {
1050
+ const nextScope = await authBinding.resolveScopeMap();
1051
+ engine.updateScope(nextScope);
1052
+ }
1053
+ const status = engine.getStatus().status;
1054
+ if (status !== "offline") {
1055
+ await engine.stop();
1056
+ }
1057
+ await engine.start();
1058
+ })();
1059
+ });
1060
+ }
228
1061
  if (config.sync && syncEngine) {
229
- connectionMonitor = new import_sync.ConnectionMonitor();
230
- reconnectionManager = new import_sync.ReconnectionManager({
1062
+ connectionMonitor = new import_sync2.ConnectionMonitor();
1063
+ reconnectionManager = new import_sync2.ReconnectionManager({
231
1064
  initialDelay: config.sync.reconnectInterval,
232
1065
  maxDelay: config.sync.maxReconnectInterval
233
1066
  });
@@ -249,10 +1082,24 @@ function createApp(config) {
249
1082
  qualityInterval = null;
250
1083
  }
251
1084
  });
1085
+ const browserGlobal = globalThis;
1086
+ if (typeof browserGlobal.addEventListener === "function") {
1087
+ const engine = syncEngine;
1088
+ browserGlobal.addEventListener("online", () => {
1089
+ if (intentionalDisconnect || config.sync?.autoReconnect === false) return;
1090
+ reconnectionManager?.wake();
1091
+ reconnectionManager?.reset();
1092
+ void engine.retryNow();
1093
+ });
1094
+ }
1095
+ emitter.on("sync:schema-mismatch", () => {
1096
+ reconnectionManager?.stop();
1097
+ intentionalDisconnect = true;
1098
+ });
252
1099
  if (config.sync.autoReconnect !== false) {
253
1100
  const engine = syncEngine;
254
1101
  emitter.on("sync:disconnected", () => {
255
- if (intentionalDisconnect) return;
1102
+ if (intentionalDisconnect || engine.isSchemaBlocked()) return;
256
1103
  if (reconnectionManager?.isRunning()) return;
257
1104
  engine.setReconnecting(true);
258
1105
  reconnectionManager?.stop();
@@ -269,9 +1116,32 @@ function createApp(config) {
269
1116
  });
270
1117
  });
271
1118
  }
1119
+ if (config.sync.autoConnect === true && syncEngine) {
1120
+ void syncEngine.start().catch(() => {
1121
+ });
1122
+ }
272
1123
  }
273
1124
  });
1125
+ const offlineSyncStatus = () => ({
1126
+ status: "offline",
1127
+ pendingOperations: 0,
1128
+ lastSyncedAt: null,
1129
+ lastSuccessfulPush: null,
1130
+ lastSuccessfulPull: null,
1131
+ conflicts: 0
1132
+ });
274
1133
  const syncControl = config.sync ? {
1134
+ get status() {
1135
+ return syncStatusBridge?.status ?? offlineSyncStatus();
1136
+ },
1137
+ subscribeStatus(listener) {
1138
+ if (syncStatusBridge) {
1139
+ return syncStatusBridge.subscribe(listener);
1140
+ }
1141
+ listener(offlineSyncStatus());
1142
+ return () => {
1143
+ };
1144
+ },
275
1145
  async connect() {
276
1146
  await ready;
277
1147
  if (syncEngine) {
@@ -279,6 +1149,7 @@ function createApp(config) {
279
1149
  reconnectionManager?.stop();
280
1150
  reconnectionManager?.reset();
281
1151
  await syncEngine.start();
1152
+ syncStatusBridge?.refresh();
282
1153
  }
283
1154
  },
284
1155
  async disconnect() {
@@ -287,19 +1158,97 @@ function createApp(config) {
287
1158
  intentionalDisconnect = true;
288
1159
  reconnectionManager?.stop();
289
1160
  await syncEngine.stop();
1161
+ syncStatusBridge?.refresh();
290
1162
  }
291
1163
  },
292
1164
  getStatus() {
293
1165
  if (syncEngine) {
294
1166
  return syncEngine.getStatus();
295
1167
  }
296
- return { status: "offline", pendingOperations: 0, lastSyncedAt: null };
1168
+ return offlineSyncStatus();
1169
+ },
1170
+ async retryNow() {
1171
+ await ready;
1172
+ if (syncEngine) {
1173
+ await syncEngine.retryNow();
1174
+ }
1175
+ },
1176
+ clearSchemaBlock() {
1177
+ syncEngine?.clearSchemaBlock();
1178
+ },
1179
+ exportDiagnostics() {
1180
+ if (syncEngine) {
1181
+ return syncEngine.exportDiagnostics();
1182
+ }
1183
+ return {
1184
+ state: "disconnected",
1185
+ status: {
1186
+ status: "offline",
1187
+ pendingOperations: 0,
1188
+ lastSyncedAt: null,
1189
+ lastSuccessfulPush: null,
1190
+ lastSuccessfulPull: null,
1191
+ conflicts: 0
1192
+ },
1193
+ nodeId: "",
1194
+ url: config.sync?.url ?? "",
1195
+ schemaVersion: config.schema.version,
1196
+ lastSyncedAt: null,
1197
+ lastSuccessfulPush: null,
1198
+ lastSuccessfulPull: null,
1199
+ conflicts: 0,
1200
+ pendingOperations: 0,
1201
+ hasInFlightBatch: false,
1202
+ reconnecting: false,
1203
+ timestamp: Date.now()
1204
+ };
297
1205
  }
298
1206
  } : null;
1207
+ async function executeTransaction(fn, mutationName) {
1208
+ await ready;
1209
+ if (!store) {
1210
+ throw new Error("Store not initialized. Await app.ready before using transactions.");
1211
+ }
1212
+ const collectionNames = Object.keys(config.schema.collections);
1213
+ return store.transaction(async (tx) => {
1214
+ if (mutationName !== void 0) {
1215
+ tx.setMutationName(mutationName);
1216
+ }
1217
+ const proxy = {};
1218
+ for (const name of collectionNames) {
1219
+ Object.defineProperty(proxy, name, {
1220
+ get() {
1221
+ return tx.collection(name);
1222
+ },
1223
+ enumerable: true,
1224
+ configurable: false
1225
+ });
1226
+ }
1227
+ await fn(proxy);
1228
+ });
1229
+ }
1230
+ const sequences = {
1231
+ async next(name, config2) {
1232
+ await ready;
1233
+ if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
1234
+ return store.getSequenceManager().next(name, config2);
1235
+ },
1236
+ async current(name, config2) {
1237
+ await ready;
1238
+ if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
1239
+ return store.getSequenceManager().current(name, config2);
1240
+ },
1241
+ async reset(name, config2) {
1242
+ await ready;
1243
+ if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
1244
+ return store.getSequenceManager().reset(name, config2);
1245
+ }
1246
+ };
299
1247
  const app = {
300
1248
  ready,
301
1249
  events: emitter,
302
1250
  sync: syncControl,
1251
+ sequences,
303
1252
  getStore() {
304
1253
  if (!store) {
305
1254
  throw new Error("Store not initialized. Await app.ready before accessing the store.");
@@ -309,6 +1258,12 @@ function createApp(config) {
309
1258
  getSyncEngine() {
310
1259
  return syncEngine;
311
1260
  },
1261
+ async transaction(fn) {
1262
+ return executeTransaction(fn);
1263
+ },
1264
+ async mutation(name, fn) {
1265
+ return executeTransaction(fn, name);
1266
+ },
312
1267
  async close() {
313
1268
  await ready;
314
1269
  intentionalDisconnect = true;
@@ -317,6 +1272,10 @@ function createApp(config) {
317
1272
  qualityInterval = null;
318
1273
  }
319
1274
  reconnectionManager?.stop();
1275
+ if (destroyDevtoolsOverlay) {
1276
+ destroyDevtoolsOverlay();
1277
+ destroyDevtoolsOverlay = null;
1278
+ }
320
1279
  if (instrumenter) {
321
1280
  instrumenter.destroy();
322
1281
  instrumenter = null;
@@ -325,15 +1284,51 @@ function createApp(config) {
325
1284
  unsubscribeSync();
326
1285
  unsubscribeSync = null;
327
1286
  }
1287
+ if (unsubscribeAudit) {
1288
+ unsubscribeAudit();
1289
+ unsubscribeAudit = null;
1290
+ }
328
1291
  if (syncEngine) {
329
1292
  await syncEngine.stop();
330
1293
  syncEngine = null;
331
1294
  }
1295
+ if (syncStatusBridge) {
1296
+ syncStatusBridge.destroy();
1297
+ syncStatusBridge = null;
1298
+ }
332
1299
  if (store) {
333
1300
  await store.close();
334
1301
  store = null;
335
1302
  }
336
1303
  emitter.clear();
1304
+ },
1305
+ async exportBackup(options) {
1306
+ await ready;
1307
+ if (!store) {
1308
+ throw new Error("Store not initialized. Await app.ready before exporting backup.");
1309
+ }
1310
+ return store.exportBackup(options);
1311
+ },
1312
+ async importBackup(data, options) {
1313
+ await ready;
1314
+ if (!store) {
1315
+ throw new Error("Store not initialized. Await app.ready before importing backup.");
1316
+ }
1317
+ return store.importBackup(data, options);
1318
+ },
1319
+ async replayTo(operationId) {
1320
+ await ready;
1321
+ if (!store) {
1322
+ throw new Error("Store not initialized. Await app.ready before replaying operations.");
1323
+ }
1324
+ return store.replayTo(operationId);
1325
+ },
1326
+ async exportAudit(options) {
1327
+ await ready;
1328
+ if (!store) {
1329
+ throw new Error("Store not initialized. Await app.ready before exporting audit data.");
1330
+ }
1331
+ return store.exportAudit(options);
337
1332
  }
338
1333
  };
339
1334
  for (const collectionName of Object.keys(config.schema.collections)) {
@@ -350,37 +1345,87 @@ function createApp(config) {
350
1345
  async function initializeAsync(config, emitter, mergeEngine) {
351
1346
  const adapterType = config.store?.adapter ?? detectAdapterType();
352
1347
  const dbName = config.store?.name ?? "kora-db";
353
- const adapter = await createAdapter(adapterType, dbName, config.store?.workerUrl);
354
- const store = new import_store.Store({
1348
+ const adapter = await createAdapter(
1349
+ adapterType,
1350
+ dbName,
1351
+ config.store?.workerUrl,
1352
+ emitter,
1353
+ config.store?.workerResponseTimeoutMs,
1354
+ config.store?.sharedWorkerUrl
1355
+ );
1356
+ const authBinding = config.sync?.authClient;
1357
+ const authNodeId = authBinding?.resolveNodeId ? await authBinding.resolveNodeId() : void 0;
1358
+ let syncEngine = null;
1359
+ const store = new import_store4.Store({
355
1360
  schema: config.schema,
356
1361
  adapter,
357
- emitter
1362
+ emitter,
1363
+ dbName,
1364
+ nodeId: authNodeId,
1365
+ isolation: authNodeId ? "shared" : config.store?.isolation,
1366
+ ...config.sync ? { onQuerySubscribed: createSyncQuerySubscriptionHook(() => syncEngine) } : {}
358
1367
  });
359
1368
  await store.open();
360
- let syncEngine = null;
1369
+ let recordConflict;
1370
+ const applyPipeline = new ApplyPipeline({
1371
+ store,
1372
+ mergeEngine,
1373
+ emitter,
1374
+ onMergeConflict: () => recordConflict?.()
1375
+ });
1376
+ store.setLocalMutationHandler(applyPipeline);
1377
+ const unsubscribeAudit = wireAuditPersistence(store, emitter);
361
1378
  let unsubscribeSync = null;
362
1379
  if (config.sync) {
363
- const transport = new import_sync.WebSocketTransport();
364
- const mergeAwareStore = new MergeAwareSyncStore(store, mergeEngine, emitter);
365
- syncEngine = new import_sync.SyncEngine({
1380
+ const transport = createSyncTransport(config.sync);
1381
+ const mergeAwareStore = new MergeAwareSyncStore(store, mergeEngine, emitter, {
1382
+ onMergeConflict: () => recordConflict?.()
1383
+ });
1384
+ let scopeMap = config.sync.scope ? (0, import_core5.buildScopeMap)(config.schema, config.sync.scope) : void 0;
1385
+ if (authBinding?.resolveScopeMap) {
1386
+ scopeMap = await authBinding.resolveScopeMap() ?? scopeMap;
1387
+ }
1388
+ const syncAuth = authBinding?.auth ?? config.sync.auth;
1389
+ const encryptor = config.sync.encryption?.enabled === true ? await import_sync2.SyncEncryptor.create(config.sync.encryption) : void 0;
1390
+ syncEngine = new import_sync2.SyncEngine({
366
1391
  transport,
367
1392
  store: mergeAwareStore,
368
1393
  config: {
369
1394
  url: config.sync.url,
370
1395
  transport: config.sync.transport,
371
- auth: config.sync.auth,
1396
+ auth: syncAuth,
372
1397
  batchSize: config.sync.batchSize,
373
- schemaVersion: config.sync.schemaVersion ?? config.schema.version
1398
+ schemaVersion: config.sync.schemaVersion ?? config.schema.version,
1399
+ scopeMap,
1400
+ encryption: config.sync.encryption,
1401
+ strictHandshake: config.sync.strictHandshake,
1402
+ operationTransforms: config.sync.operationTransforms
374
1403
  },
375
- emitter
1404
+ emitter,
1405
+ queueStorage: new StoreQueueStorage(adapter),
1406
+ syncState: new StoreSyncStatePersistence(store, scopeMap),
1407
+ encryptor
376
1408
  });
1409
+ recordConflict = () => syncEngine?.recordConflict();
377
1410
  unsubscribeSync = emitter.on("operation:created", (event) => {
378
1411
  if (syncEngine) {
379
1412
  syncEngine.pushOperation(event.operation);
380
1413
  }
381
1414
  });
382
1415
  }
383
- return { store, syncEngine, unsubscribeSync };
1416
+ return {
1417
+ store,
1418
+ syncEngine,
1419
+ unsubscribeSync,
1420
+ unsubscribeAudit,
1421
+ authBinding: config.sync?.authClient ?? null
1422
+ };
1423
+ }
1424
+ function createSyncTransport(sync) {
1425
+ if (sync.transport === "http") {
1426
+ return new import_sync2.HttpLongPollingTransport();
1427
+ }
1428
+ return new import_sync2.WebSocketTransport();
384
1429
  }
385
1430
  function createCollectionAccessor(collectionName, getStore) {
386
1431
  return {
@@ -445,15 +1490,19 @@ function createPendingQueryBuilder(initialWhere) {
445
1490
  return this;
446
1491
  },
447
1492
  async exec() {
448
- return [];
1493
+ throw new AppNotReadyError(
1494
+ "Cannot execute a query before app.ready. Await app.ready or use <KoraProvider app={app}>."
1495
+ );
449
1496
  },
450
1497
  async count() {
451
- return 0;
1498
+ throw new AppNotReadyError(
1499
+ "Cannot count query results before app.ready. Await app.ready or use <KoraProvider app={app}>."
1500
+ );
452
1501
  },
453
- subscribe(callback) {
454
- void callback([]);
455
- return () => {
456
- };
1502
+ subscribe(_callback) {
1503
+ throw new AppNotReadyError(
1504
+ "Cannot subscribe to a query before app.ready. Await app.ready or use <KoraProvider app={app}>."
1505
+ );
457
1506
  },
458
1507
  getDescriptor() {
459
1508
  return { ...descriptor, where: { ...descriptor.where }, orderBy: [...descriptor.orderBy] };
@@ -463,26 +1512,41 @@ function createPendingQueryBuilder(initialWhere) {
463
1512
  }
464
1513
 
465
1514
  // src/index.ts
466
- var import_core = require("@korajs/core");
467
- var import_core2 = require("@korajs/core");
468
- var import_core3 = require("@korajs/core");
469
- var import_core4 = require("@korajs/core");
470
- var import_core5 = require("@korajs/core");
471
- var import_store2 = require("@korajs/store");
472
- var import_merge2 = require("@korajs/merge");
473
- var import_sync2 = require("@korajs/sync");
1515
+ var import_store5 = require("@korajs/store");
1516
+ var import_core6 = require("@korajs/core");
1517
+ var import_core7 = require("@korajs/core");
1518
+ var import_core8 = require("@korajs/core");
1519
+ var import_core9 = require("@korajs/core");
1520
+ var import_core10 = require("@korajs/core");
1521
+ var import_core11 = require("@korajs/core");
1522
+ var import_store6 = require("@korajs/store");
1523
+ var import_store7 = require("@korajs/store");
1524
+ var import_store8 = require("@korajs/store");
1525
+ var import_merge3 = require("@korajs/merge");
1526
+ var import_sync3 = require("@korajs/sync");
474
1527
  // Annotate the CommonJS export names for ESM import in node:
475
1528
  0 && (module.exports = {
476
1529
  HybridLogicalClock,
477
1530
  KoraError,
478
1531
  MergeEngine,
1532
+ SequenceManager,
479
1533
  Store,
480
1534
  SyncEngine,
1535
+ TransactionContext,
481
1536
  WebSocketTransport,
482
1537
  createApp,
483
1538
  createOperation,
1539
+ decodeAuditExport,
484
1540
  defineSchema,
1541
+ exportBackup,
485
1542
  generateUUIDv7,
486
- t
1543
+ migrate,
1544
+ op,
1545
+ readAuditExportManifest,
1546
+ readBackupManifest,
1547
+ restoreBackup,
1548
+ t,
1549
+ verifyAuditExportChecksum,
1550
+ verifyBackupChecksum
487
1551
  });
488
1552
  //# sourceMappingURL=index.cjs.map