korajs 0.5.0 → 0.6.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.js CHANGED
@@ -6,19 +6,57 @@ import {
6
6
  } from "./chunk-WALHLVVF.js";
7
7
 
8
8
  // src/create-app.ts
9
- import { buildScopeMap } from "@korajs/core";
10
9
  import { SimpleEventEmitter } from "@korajs/core/internal";
11
- import { Instrumenter } from "@korajs/devtools";
12
10
  import { MergeEngine } from "@korajs/merge";
11
+ import { QueryStoreCache } from "@korajs/store";
12
+
13
+ // src/collection-accessor.ts
14
+ import { AppNotReadyError } from "@korajs/core";
15
+ function createCollectionAccessor(collectionName, getStore) {
16
+ const notReady = (action) => new AppNotReadyError(
17
+ `Cannot ${action} on collection "${collectionName}" before app.ready. Await app.ready or use <KoraProvider app={app}>.`
18
+ );
19
+ return {
20
+ async insert(data) {
21
+ const currentStore = getStore();
22
+ if (!currentStore) {
23
+ throw notReady("insert into");
24
+ }
25
+ return currentStore.collection(collectionName).insert(data);
26
+ },
27
+ async findById(id) {
28
+ const currentStore = getStore();
29
+ if (!currentStore) return null;
30
+ return currentStore.collection(collectionName).findById(id);
31
+ },
32
+ async update(id, data) {
33
+ const currentStore = getStore();
34
+ if (!currentStore) {
35
+ throw notReady("update");
36
+ }
37
+ return currentStore.collection(collectionName).update(id, data);
38
+ },
39
+ async delete(id) {
40
+ const currentStore = getStore();
41
+ if (!currentStore) {
42
+ throw notReady("delete from");
43
+ }
44
+ return currentStore.collection(collectionName).delete(id);
45
+ },
46
+ where(conditions) {
47
+ const currentStore = getStore();
48
+ if (!currentStore) {
49
+ throw notReady("query");
50
+ }
51
+ return currentStore.collection(collectionName).where(conditions);
52
+ }
53
+ };
54
+ }
55
+
56
+ // src/initialize-app.ts
57
+ import { buildScopeMap } from "@korajs/core";
13
58
  import { Store } from "@korajs/store";
14
- import {
15
- ConnectionMonitor,
16
- HttpLongPollingTransport,
17
- ReconnectionManager,
18
- SyncEncryptor,
19
- SyncEngine,
20
- WebSocketTransport
21
- } from "@korajs/sync";
59
+ import { SyncEncryptor, SyncEngine } from "@korajs/sync";
22
60
 
23
61
  // src/adapter-resolver.ts
24
62
  function importOptionalPeer(specifier) {
@@ -68,17 +106,6 @@ async function createAdapter(type, dbName, workerUrl, emitter, workerResponseTim
68
106
  }
69
107
  }
70
108
 
71
- // src/app-not-ready-error.ts
72
- import { KoraError } from "@korajs/core";
73
- var AppNotReadyError = class extends KoraError {
74
- constructor(detail) {
75
- super(detail, "APP_NOT_READY", {
76
- fix: "Await app.ready, or wrap your UI in <KoraProvider app={app}> before calling useQuery()."
77
- });
78
- this.name = "AppNotReadyError";
79
- }
80
- };
81
-
82
109
  // src/audit-bridge.ts
83
110
  import { persistedAuditTraceFromEvent } from "@korajs/store";
84
111
  var AUDIT_EVENT_TYPES = ["merge:completed", "merge:conflict", "constraint:violated"];
@@ -103,9 +130,19 @@ function wireAuditPersistence(store, emitter) {
103
130
  };
104
131
  }
105
132
 
133
+ // src/create-sync-transport.ts
134
+ import { HttpLongPollingTransport, WebSocketTransport } from "@korajs/sync";
135
+ function createSyncTransport(sync) {
136
+ if (sync.transport === "http") {
137
+ return new HttpLongPollingTransport();
138
+ }
139
+ return new WebSocketTransport();
140
+ }
141
+
106
142
  // src/sync-query-bridge.ts
107
143
  function queryDescriptorToSyncSubset(descriptor) {
108
144
  const where = {};
145
+ const skippedFields = [];
109
146
  for (const [field, value] of Object.entries(descriptor.where)) {
110
147
  if (value === null || value === void 0) {
111
148
  where[field] = value;
@@ -113,7 +150,14 @@ function queryDescriptorToSyncSubset(descriptor) {
113
150
  }
114
151
  if (typeof value !== "object" || Array.isArray(value)) {
115
152
  where[field] = value;
153
+ continue;
116
154
  }
155
+ skippedFields.push(field);
156
+ }
157
+ if (skippedFields.length > 0 && typeof console !== "undefined") {
158
+ console.warn(
159
+ `[Kora] Sync query subset omitted non-equality filters on ${descriptor.collection}: ${skippedFields.join(", ")}. Only plain equality WHERE clauses are registered for incremental sync.`
160
+ );
117
161
  }
118
162
  if (Object.keys(where).length === 0) {
119
163
  return null;
@@ -139,297 +183,148 @@ function createSyncQuerySubscriptionHook(getSyncEngine) {
139
183
  };
140
184
  }
141
185
 
142
- // src/sync-status-bridge.ts
143
- var OFFLINE_STATUS = Object.freeze({
144
- status: "offline",
145
- pendingOperations: 0,
146
- lastSyncedAt: null,
147
- lastSuccessfulPush: null,
148
- lastSuccessfulPull: null,
149
- conflicts: 0
150
- });
151
- var SYNC_STATUS_EVENT_TYPES = [
152
- "sync:connected",
153
- "sync:disconnected",
154
- "sync:schema-mismatch",
155
- "sync:auth-failed",
156
- "sync:sent",
157
- "sync:received",
158
- "sync:acknowledged",
159
- "sync:apply-failed",
160
- "sync:diagnostics",
161
- "sync:initial-sync-progress"
162
- ];
163
- function createSyncStatusBridge(emitter, getSyncEngine) {
164
- let currentStatus = OFFLINE_STATUS;
165
- const listeners = /* @__PURE__ */ new Set();
166
- const refresh = () => {
167
- const engine = getSyncEngine();
168
- const next = engine ? engine.getStatus() : OFFLINE_STATUS;
169
- const prevSerialized = JSON.stringify(currentStatus);
170
- const nextSerialized = JSON.stringify(next);
171
- if (prevSerialized === nextSerialized) {
172
- return;
186
+ // src/initialize-app.ts
187
+ async function initializeApp(config, emitter, mergeEngine) {
188
+ const adapterType = config.store?.adapter ?? detectAdapterType();
189
+ const dbName = config.store?.name ?? "kora-db";
190
+ const adapter = await createAdapter(
191
+ adapterType,
192
+ dbName,
193
+ config.store?.workerUrl,
194
+ emitter,
195
+ config.store?.workerResponseTimeoutMs,
196
+ config.store?.sharedWorkerUrl
197
+ );
198
+ const authBinding = config.sync?.authClient ?? null;
199
+ const authNodeId = authBinding?.resolveNodeId ? await authBinding.resolveNodeId() : void 0;
200
+ let syncEngine = null;
201
+ const store = new Store({
202
+ schema: config.schema,
203
+ adapter,
204
+ emitter,
205
+ dbName,
206
+ nodeId: authNodeId,
207
+ isolation: authNodeId ? "shared" : config.store?.isolation,
208
+ ...config.sync ? { onQuerySubscribed: createSyncQuerySubscriptionHook(() => syncEngine) } : {}
209
+ });
210
+ await store.open();
211
+ let recordConflict;
212
+ const applyPipeline = new ApplyPipeline({
213
+ store,
214
+ mergeEngine,
215
+ emitter,
216
+ onMergeConflict: () => recordConflict?.()
217
+ });
218
+ store.setLocalMutationHandler(applyPipeline);
219
+ const unsubscribeAudit = wireAuditPersistence(store, emitter);
220
+ let unsubscribeSync = null;
221
+ if (config.sync) {
222
+ const transport = createSyncTransport(config.sync);
223
+ const mergeAwareStore = new MergeAwareSyncStore(store, mergeEngine, emitter, {
224
+ onMergeConflict: () => recordConflict?.()
225
+ });
226
+ let scopeMap = config.sync.scope ? buildScopeMap(config.schema, config.sync.scope) : void 0;
227
+ if (authBinding?.resolveScopeMap) {
228
+ scopeMap = await authBinding.resolveScopeMap() ?? scopeMap;
173
229
  }
174
- currentStatus = next;
175
- for (const listener of listeners) {
176
- listener(currentStatus);
230
+ const syncAuth = authBinding?.auth ?? config.sync.auth;
231
+ const encryptor = config.sync.encryption?.enabled === true ? await SyncEncryptor.create(config.sync.encryption) : void 0;
232
+ syncEngine = new SyncEngine({
233
+ transport,
234
+ store: mergeAwareStore,
235
+ config: {
236
+ url: config.sync.url,
237
+ transport: config.sync.transport,
238
+ auth: syncAuth,
239
+ batchSize: config.sync.batchSize,
240
+ schemaVersion: config.sync.schemaVersion ?? config.schema.version,
241
+ scopeMap,
242
+ encryption: config.sync.encryption,
243
+ strictHandshake: config.sync.strictHandshake,
244
+ operationTransforms: config.sync.operationTransforms
245
+ },
246
+ emitter,
247
+ queueStorage: new StoreQueueStorage(adapter),
248
+ syncState: new StoreSyncStatePersistence(store, scopeMap),
249
+ encryptor
250
+ });
251
+ recordConflict = () => syncEngine?.recordConflict();
252
+ unsubscribeSync = emitter.on("operation:created", (event) => {
253
+ if (syncEngine) {
254
+ syncEngine.pushOperation(event.operation);
255
+ }
256
+ });
257
+ }
258
+ return {
259
+ store,
260
+ syncEngine,
261
+ unsubscribeSync,
262
+ unsubscribeAudit,
263
+ authBinding
264
+ };
265
+ }
266
+
267
+ // src/sequences-accessor.ts
268
+ function createSequencesAccessor(ready, getStore) {
269
+ const requireStore = async () => {
270
+ await ready;
271
+ const store = getStore();
272
+ if (!store) {
273
+ throw new Error("Store not initialized. Await app.ready before using sequences.");
177
274
  }
275
+ return store;
178
276
  };
179
- const unsubs = [];
180
- for (const type of SYNC_STATUS_EVENT_TYPES) {
181
- unsubs.push(emitter.on(type, refresh));
182
- }
183
- const bridge = {
184
- get status() {
185
- return currentStatus;
277
+ return {
278
+ async next(name, config) {
279
+ const store = await requireStore();
280
+ return store.getSequenceManager().next(name, config);
186
281
  },
187
- subscribe(listener) {
188
- listeners.add(listener);
189
- listener(currentStatus);
190
- return () => {
191
- listeners.delete(listener);
192
- };
282
+ async current(name, config) {
283
+ const store = await requireStore();
284
+ return store.getSequenceManager().current(name, config);
193
285
  },
194
- refresh,
195
- destroy() {
196
- for (const unsub of unsubs) {
197
- unsub();
198
- }
199
- unsubs.length = 0;
200
- listeners.clear();
286
+ async reset(name, config) {
287
+ const store = await requireStore();
288
+ return store.getSequenceManager().reset(name, config);
201
289
  }
202
290
  };
203
- refresh();
204
- return bridge;
205
291
  }
206
292
 
207
- // src/validate-config.ts
208
- import { KoraError as KoraError2, SchemaValidationError } from "@korajs/core";
209
- function validateCreateAppConfig(config) {
210
- if (!config.schema) {
211
- throw new SchemaValidationError("createApp requires a schema.", {
212
- fix: "Pass schema: defineSchema({ version: 1, collections: { ... } })"
213
- });
214
- }
215
- if (config.schema.version < 1) {
216
- throw new SchemaValidationError("Schema version must be at least 1.", {
217
- version: config.schema.version
218
- });
219
- }
220
- const collectionNames = Object.keys(config.schema.collections);
221
- if (collectionNames.length === 0) {
222
- throw new SchemaValidationError("Schema must define at least one collection.", {
223
- fix: "Add entries under collections in defineSchema()."
224
- });
225
- }
226
- if (config.sync) {
227
- validateSyncUrl(config.sync.url, config.sync.transport ?? "websocket");
228
- }
229
- const adapter = config.store?.adapter ?? detectAdapterType();
230
- const isBrowser = typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined";
231
- if (isBrowser && (adapter === "sqlite-wasm" || adapter === "indexeddb") && !config.store?.workerUrl) {
232
- throw new KoraError2(
233
- "Browser storage requires store.workerUrl pointing to the SQLite WASM worker script.",
234
- "MISSING_WORKER_URL",
235
- {
236
- adapter,
237
- fix: 'Add store: { workerUrl: "/sqlite-wasm-worker.js" } (see create-kora-app templates).'
238
- }
239
- );
293
+ // src/setup-devtools.ts
294
+ import { Instrumenter } from "@korajs/devtools";
295
+ function setupDevtools(config, emitter) {
296
+ if (!config.devtools) {
297
+ return { instrumenter: null, destroyOverlay: null };
240
298
  }
241
- }
242
- function validateSyncUrl(url, transport) {
243
- if (!url || url.trim().length === 0) {
244
- throw new KoraError2("sync.url is required when sync is configured.", "INVALID_SYNC_URL", {
245
- fix: 'Pass sync: { url: "wss://your-server/kora" }.'
299
+ const instrumenter = new Instrumenter(emitter, {
300
+ bridgeEnabled: typeof globalThis !== "undefined" && "window" in globalThis
301
+ });
302
+ let destroyOverlay = null;
303
+ if (typeof globalThis !== "undefined" && "window" in globalThis) {
304
+ void import("@korajs/devtools/overlay").then(({ mountKoraDevtoolsOverlay }) => {
305
+ destroyOverlay = mountKoraDevtoolsOverlay(instrumenter);
306
+ }).catch(() => {
246
307
  });
247
308
  }
248
- try {
249
- const parsed = new URL(url);
250
- if (transport === "http") {
251
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
252
- throw new Error("bad protocol");
253
- }
254
- } else if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
255
- throw new Error("bad protocol");
256
- }
257
- } catch {
258
- throw new KoraError2(
259
- `Invalid sync URL "${url}" for transport "${transport}".`,
260
- "INVALID_SYNC_URL",
261
- {
262
- url,
263
- transport,
264
- fix: transport === "http" ? "Use an absolute http:// or https:// URL." : "Use an absolute ws:// or wss:// URL."
265
- }
266
- );
267
- }
309
+ return { instrumenter, destroyOverlay: () => destroyOverlay?.() };
268
310
  }
269
311
 
270
- // src/create-app.ts
271
- function createApp(config) {
272
- validateCreateAppConfig(config);
273
- const emitter = new SimpleEventEmitter();
274
- const mergeEngine = new MergeEngine();
275
- if (config.onSyncEvent) {
276
- const handler = config.onSyncEvent;
277
- const syncTypes = [
278
- "sync:connected",
279
- "sync:disconnected",
280
- "sync:schema-mismatch",
281
- "sync:auth-failed",
282
- "sync:sent",
283
- "sync:received",
284
- "sync:acknowledged",
285
- "sync:apply-failed",
286
- "sync:diagnostics",
287
- "sync:bandwidth",
288
- "sync:initial-sync-progress"
289
- ];
290
- for (const type of syncTypes) {
291
- emitter.on(type, (event) => {
292
- handler(event);
293
- });
294
- }
295
- }
296
- let store = null;
297
- let syncEngine = null;
298
- let unsubscribeSync = null;
299
- let unsubscribeAudit = null;
300
- let reconnectionManager = null;
301
- let connectionMonitor = null;
302
- let instrumenter = null;
303
- let intentionalDisconnect = false;
304
- let qualityInterval = null;
305
- let syncStatusBridge = null;
306
- let destroyDevtoolsOverlay = null;
307
- if (config.devtools) {
308
- instrumenter = new Instrumenter(emitter, {
309
- bridgeEnabled: typeof globalThis !== "undefined" && "window" in globalThis
310
- });
311
- if (typeof globalThis !== "undefined" && "window" in globalThis) {
312
- void import("@korajs/devtools/overlay").then(({ mountKoraDevtoolsOverlay }) => {
313
- if (instrumenter) {
314
- destroyDevtoolsOverlay = mountKoraDevtoolsOverlay(instrumenter);
315
- }
316
- }).catch(() => {
317
- });
318
- }
312
+ // src/sync-control.ts
313
+ import { OFFLINE_SYNC_STATUS } from "@korajs/sync";
314
+ function createSyncControl(options) {
315
+ const { config, ready, state } = options;
316
+ if (!config.sync) {
317
+ return null;
319
318
  }
320
- const ready = initializeAsync(config, emitter, mergeEngine).then((result) => {
321
- store = result.store;
322
- syncEngine = result.syncEngine;
323
- unsubscribeSync = result.unsubscribeSync;
324
- unsubscribeAudit = result.unsubscribeAudit;
325
- const authBinding = result.authBinding;
326
- if (config.sync) {
327
- syncStatusBridge = createSyncStatusBridge(emitter, () => syncEngine);
328
- syncStatusBridge.refresh();
329
- }
330
- if (config.sync && syncEngine && authBinding?.subscribe) {
331
- authBinding.subscribe(() => {
332
- const engine = syncEngine;
333
- if (!engine) {
334
- return;
335
- }
336
- void (async () => {
337
- const headers = await authBinding.auth();
338
- if (!headers.token) {
339
- await engine.stop();
340
- return;
341
- }
342
- if (authBinding.resolveScopeMap) {
343
- const nextScope = await authBinding.resolveScopeMap();
344
- engine.updateScope(nextScope);
345
- }
346
- const status = engine.getStatus().status;
347
- if (status !== "offline") {
348
- await engine.stop();
349
- }
350
- await engine.start();
351
- })();
352
- });
353
- }
354
- if (config.sync && syncEngine) {
355
- connectionMonitor = new ConnectionMonitor();
356
- reconnectionManager = new ReconnectionManager({
357
- initialDelay: config.sync.reconnectInterval,
358
- maxDelay: config.sync.maxReconnectInterval
359
- });
360
- emitter.on("sync:sent", () => connectionMonitor?.recordActivity());
361
- emitter.on("sync:received", () => connectionMonitor?.recordActivity());
362
- emitter.on("sync:acknowledged", () => connectionMonitor?.recordActivity());
363
- emitter.on("sync:connected", () => {
364
- if (qualityInterval !== null) clearInterval(qualityInterval);
365
- qualityInterval = setInterval(() => {
366
- if (connectionMonitor) {
367
- emitter.emit({ type: "connection:quality", quality: connectionMonitor.getQuality() });
368
- }
369
- }, 5e3);
370
- });
371
- emitter.on("sync:disconnected", () => {
372
- connectionMonitor?.reset();
373
- if (qualityInterval !== null) {
374
- clearInterval(qualityInterval);
375
- qualityInterval = null;
376
- }
377
- });
378
- const browserGlobal = globalThis;
379
- if (typeof browserGlobal.addEventListener === "function") {
380
- const engine = syncEngine;
381
- browserGlobal.addEventListener("online", () => {
382
- if (intentionalDisconnect || config.sync?.autoReconnect === false) return;
383
- reconnectionManager?.wake();
384
- reconnectionManager?.reset();
385
- void engine.retryNow();
386
- });
387
- }
388
- emitter.on("sync:schema-mismatch", () => {
389
- reconnectionManager?.stop();
390
- intentionalDisconnect = true;
391
- });
392
- if (config.sync.autoReconnect !== false) {
393
- const engine = syncEngine;
394
- emitter.on("sync:disconnected", () => {
395
- if (intentionalDisconnect || engine.isSchemaBlocked()) return;
396
- if (reconnectionManager?.isRunning()) return;
397
- engine.setReconnecting(true);
398
- reconnectionManager?.stop();
399
- reconnectionManager?.start(async () => {
400
- try {
401
- await engine.start();
402
- engine.setReconnecting(false);
403
- return true;
404
- } catch {
405
- return false;
406
- }
407
- }).then(() => {
408
- engine.setReconnecting(false);
409
- });
410
- });
411
- }
412
- if (config.sync.autoConnect === true && syncEngine) {
413
- void syncEngine.start().catch(() => {
414
- });
415
- }
416
- }
417
- });
418
- const offlineSyncStatus = () => ({
419
- status: "offline",
420
- pendingOperations: 0,
421
- lastSyncedAt: null,
422
- lastSuccessfulPush: null,
423
- lastSuccessfulPull: null,
424
- conflicts: 0
425
- });
426
- const syncControl = config.sync ? {
319
+ const offlineSyncStatus = () => OFFLINE_SYNC_STATUS;
320
+ const bridgeStatus = () => state.syncStatusBridge?.status ?? offlineSyncStatus();
321
+ return {
427
322
  get status() {
428
- return syncStatusBridge?.status ?? offlineSyncStatus();
323
+ return bridgeStatus();
429
324
  },
430
325
  subscribeStatus(listener) {
431
- if (syncStatusBridge) {
432
- return syncStatusBridge.subscribe(listener);
326
+ if (state.syncStatusBridge) {
327
+ return state.syncStatusBridge.subscribe(listener);
433
328
  }
434
329
  listener(offlineSyncStatus());
435
330
  return () => {
@@ -437,41 +332,41 @@ function createApp(config) {
437
332
  },
438
333
  async connect() {
439
334
  await ready;
440
- if (syncEngine) {
441
- intentionalDisconnect = false;
442
- reconnectionManager?.stop();
443
- reconnectionManager?.reset();
444
- await syncEngine.start();
445
- syncStatusBridge?.refresh();
335
+ if (state.syncEngine) {
336
+ state.intentionalDisconnect = false;
337
+ state.reconnectionManager?.stop();
338
+ state.reconnectionManager?.reset();
339
+ await state.syncEngine.start();
340
+ state.syncStatusBridge?.refresh();
446
341
  }
447
342
  },
448
343
  async disconnect() {
449
344
  await ready;
450
- if (syncEngine) {
451
- intentionalDisconnect = true;
452
- reconnectionManager?.stop();
453
- await syncEngine.stop();
454
- syncStatusBridge?.refresh();
345
+ if (state.syncEngine) {
346
+ state.intentionalDisconnect = true;
347
+ state.reconnectionManager?.stop();
348
+ await state.syncEngine.stop();
349
+ state.syncStatusBridge?.refresh();
455
350
  }
456
351
  },
457
352
  getStatus() {
458
- if (syncEngine) {
459
- return syncEngine.getStatus();
353
+ if (state.syncEngine) {
354
+ return state.syncEngine.getStatus();
460
355
  }
461
356
  return offlineSyncStatus();
462
357
  },
463
358
  async retryNow() {
464
359
  await ready;
465
- if (syncEngine) {
466
- await syncEngine.retryNow();
360
+ if (state.syncEngine) {
361
+ await state.syncEngine.retryNow();
467
362
  }
468
363
  },
469
364
  clearSchemaBlock() {
470
- syncEngine?.clearSchemaBlock();
365
+ state.syncEngine?.clearSchemaBlock();
471
366
  },
472
367
  exportDiagnostics() {
473
- if (syncEngine) {
474
- return syncEngine.exportDiagnostics();
368
+ if (state.syncEngine) {
369
+ return state.syncEngine.exportDiagnostics();
475
370
  }
476
371
  return {
477
372
  state: "disconnected",
@@ -496,10 +391,201 @@ function createApp(config) {
496
391
  timestamp: Date.now()
497
392
  };
498
393
  }
499
- } : null;
500
- async function executeTransaction(fn, mutationName) {
501
- await ready;
502
- if (!store) {
394
+ };
395
+ }
396
+
397
+ // src/auth-sync-coordinator.ts
398
+ var AuthSyncCoordinator = class {
399
+ constructor(getEngine, authBinding) {
400
+ this.getEngine = getEngine;
401
+ this.authBinding = authBinding;
402
+ }
403
+ getEngine;
404
+ authBinding;
405
+ inFlight = null;
406
+ pending = false;
407
+ disposed = false;
408
+ scheduleReconnect() {
409
+ if (this.disposed) {
410
+ return;
411
+ }
412
+ if (this.inFlight) {
413
+ this.pending = true;
414
+ return;
415
+ }
416
+ this.inFlight = this.run().finally(() => {
417
+ this.inFlight = null;
418
+ if (this.pending && !this.disposed) {
419
+ this.pending = false;
420
+ this.scheduleReconnect();
421
+ }
422
+ });
423
+ }
424
+ destroy() {
425
+ this.disposed = true;
426
+ this.pending = false;
427
+ }
428
+ async run() {
429
+ const engine = this.getEngine();
430
+ if (!engine) {
431
+ return;
432
+ }
433
+ const headers = await this.authBinding.auth();
434
+ if (!headers.token) {
435
+ await engine.stop();
436
+ return;
437
+ }
438
+ if (this.authBinding.resolveScopeMap) {
439
+ const nextScope = await this.authBinding.resolveScopeMap();
440
+ engine.updateScope(nextScope);
441
+ }
442
+ const status = engine.getStatus().status;
443
+ if (status !== "offline") {
444
+ await engine.stop();
445
+ }
446
+ await engine.start();
447
+ }
448
+ };
449
+
450
+ // src/sync-status-bridge.ts
451
+ import { createSyncStatusController } from "@korajs/sync";
452
+ function createSyncStatusBridge(emitter, getSyncEngine) {
453
+ const controller = createSyncStatusController({
454
+ getSyncEngine,
455
+ subscribeSyncStatus: null,
456
+ events: emitter
457
+ });
458
+ return {
459
+ get status() {
460
+ return controller.getSnapshot();
461
+ },
462
+ subscribe(listener) {
463
+ return controller.subscribe(() => {
464
+ listener(controller.getSnapshot());
465
+ });
466
+ },
467
+ refresh: () => controller.refresh(),
468
+ destroy: () => controller.destroy()
469
+ };
470
+ }
471
+
472
+ // src/sync-lifecycle.ts
473
+ import {
474
+ ConnectionMonitor,
475
+ ReconnectionManager
476
+ } from "@korajs/sync";
477
+ function wireSyncLifecycleAfterReady(config, emitter, state, init) {
478
+ state.syncEngine = init.syncEngine;
479
+ if (!config.sync) {
480
+ return;
481
+ }
482
+ state.syncStatusBridge = createSyncStatusBridge(emitter, () => state.syncEngine);
483
+ state.syncStatusBridge.refresh();
484
+ if (state.syncEngine && init.authBinding?.subscribe) {
485
+ state.authSyncCoordinator = new AuthSyncCoordinator(() => state.syncEngine, init.authBinding);
486
+ init.authBinding.subscribe(() => {
487
+ state.authSyncCoordinator?.scheduleReconnect();
488
+ });
489
+ }
490
+ if (!state.syncEngine) {
491
+ return;
492
+ }
493
+ const syncEngine = state.syncEngine;
494
+ state.connectionMonitor = new ConnectionMonitor();
495
+ state.reconnectionManager = new ReconnectionManager({
496
+ initialDelay: config.sync.reconnectInterval,
497
+ maxDelay: config.sync.maxReconnectInterval
498
+ });
499
+ emitter.on("sync:sent", () => state.connectionMonitor?.recordActivity());
500
+ emitter.on("sync:received", () => state.connectionMonitor?.recordActivity());
501
+ emitter.on("sync:acknowledged", () => state.connectionMonitor?.recordActivity());
502
+ emitter.on("sync:connected", () => {
503
+ if (state.qualityInterval !== null) {
504
+ clearInterval(state.qualityInterval);
505
+ }
506
+ state.qualityInterval = setInterval(() => {
507
+ if (state.connectionMonitor) {
508
+ emitter.emit({
509
+ type: "connection:quality",
510
+ quality: state.connectionMonitor.getQuality()
511
+ });
512
+ }
513
+ }, 5e3);
514
+ });
515
+ emitter.on("sync:disconnected", () => {
516
+ state.connectionMonitor?.reset();
517
+ if (state.qualityInterval !== null) {
518
+ clearInterval(state.qualityInterval);
519
+ state.qualityInterval = null;
520
+ }
521
+ });
522
+ const browserGlobal = globalThis;
523
+ if (typeof browserGlobal.addEventListener === "function") {
524
+ const onOnline = () => {
525
+ if (state.intentionalDisconnect || config.sync?.autoReconnect === false) {
526
+ return;
527
+ }
528
+ state.reconnectionManager?.wake();
529
+ state.reconnectionManager?.reset();
530
+ void syncEngine.retryNow();
531
+ };
532
+ browserGlobal.addEventListener("online", onOnline);
533
+ state.removeOnlineListener = () => {
534
+ browserGlobal.removeEventListener?.("online", onOnline);
535
+ };
536
+ }
537
+ emitter.on("sync:schema-mismatch", () => {
538
+ state.reconnectionManager?.stop();
539
+ state.intentionalDisconnect = true;
540
+ });
541
+ if (config.sync.autoReconnect !== false) {
542
+ emitter.on("sync:disconnected", () => {
543
+ if (state.intentionalDisconnect || syncEngine.isSchemaBlocked()) {
544
+ return;
545
+ }
546
+ if (state.reconnectionManager?.isRunning()) {
547
+ return;
548
+ }
549
+ syncEngine.setReconnecting(true);
550
+ state.reconnectionManager?.stop();
551
+ state.reconnectionManager?.start(async () => {
552
+ try {
553
+ await syncEngine.start();
554
+ syncEngine.setReconnecting(false);
555
+ return true;
556
+ } catch {
557
+ return false;
558
+ }
559
+ }).then(() => {
560
+ syncEngine.setReconnecting(false);
561
+ });
562
+ });
563
+ }
564
+ if (config.sync.autoConnect === true) {
565
+ void syncEngine.start().catch(() => {
566
+ });
567
+ }
568
+ }
569
+ function teardownSyncLifecycle(state) {
570
+ if (state.qualityInterval !== null) {
571
+ clearInterval(state.qualityInterval);
572
+ state.qualityInterval = null;
573
+ }
574
+ state.reconnectionManager?.stop();
575
+ state.syncStatusBridge?.destroy();
576
+ state.syncStatusBridge = null;
577
+ state.authSyncCoordinator?.destroy();
578
+ state.authSyncCoordinator = null;
579
+ state.removeOnlineListener?.();
580
+ state.removeOnlineListener = null;
581
+ }
582
+
583
+ // src/transaction-executor.ts
584
+ function createTransactionExecutor(config, ready, getStore) {
585
+ return async (fn, mutationName) => {
586
+ await ready;
587
+ const store = getStore();
588
+ if (!store) {
503
589
  throw new Error("Store not initialized. Await app.ready before using transactions.");
504
590
  }
505
591
  const collectionNames = Object.keys(config.schema.collections);
@@ -519,29 +605,130 @@ function createApp(config) {
519
605
  }
520
606
  await fn(proxy);
521
607
  });
608
+ };
609
+ }
610
+
611
+ // src/validate-config.ts
612
+ import { KoraError, SchemaValidationError } from "@korajs/core";
613
+ function validateCreateAppConfig(config) {
614
+ if (!config.schema) {
615
+ throw new SchemaValidationError("createApp requires a schema.", {
616
+ fix: "Pass schema: defineSchema({ version: 1, collections: { ... } })"
617
+ });
522
618
  }
523
- const sequences = {
524
- async next(name, config2) {
525
- await ready;
526
- if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
527
- return store.getSequenceManager().next(name, config2);
528
- },
529
- async current(name, config2) {
530
- await ready;
531
- if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
532
- return store.getSequenceManager().current(name, config2);
533
- },
534
- async reset(name, config2) {
535
- await ready;
536
- if (!store) throw new Error("Store not initialized. Await app.ready before using sequences.");
537
- return store.getSequenceManager().reset(name, config2);
619
+ if (config.schema.version < 1) {
620
+ throw new SchemaValidationError("Schema version must be at least 1.", {
621
+ version: config.schema.version
622
+ });
623
+ }
624
+ const collectionNames = Object.keys(config.schema.collections);
625
+ if (collectionNames.length === 0) {
626
+ throw new SchemaValidationError("Schema must define at least one collection.", {
627
+ fix: "Add entries under collections in defineSchema()."
628
+ });
629
+ }
630
+ if (config.sync) {
631
+ validateSyncUrl(config.sync.url, config.sync.transport ?? "websocket");
632
+ }
633
+ const adapter = config.store?.adapter ?? detectAdapterType();
634
+ const isBrowser = typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined";
635
+ if (isBrowser && (adapter === "sqlite-wasm" || adapter === "indexeddb") && !config.store?.workerUrl) {
636
+ throw new KoraError(
637
+ "Browser storage requires store.workerUrl pointing to the SQLite WASM worker script.",
638
+ "MISSING_WORKER_URL",
639
+ {
640
+ adapter,
641
+ fix: 'Add store: { workerUrl: "/sqlite-wasm-worker.js" } (see create-kora-app templates).'
642
+ }
643
+ );
644
+ }
645
+ }
646
+ function validateSyncUrl(url, transport) {
647
+ if (!url || url.trim().length === 0) {
648
+ throw new KoraError("sync.url is required when sync is configured.", "INVALID_SYNC_URL", {
649
+ fix: 'Pass sync: { url: "wss://your-server/kora" }.'
650
+ });
651
+ }
652
+ try {
653
+ const parsed = new URL(url);
654
+ if (transport === "http") {
655
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
656
+ throw new Error("bad protocol");
657
+ }
658
+ } else if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
659
+ throw new Error("bad protocol");
538
660
  }
661
+ } catch {
662
+ throw new KoraError(
663
+ `Invalid sync URL "${url}" for transport "${transport}".`,
664
+ "INVALID_SYNC_URL",
665
+ {
666
+ url,
667
+ transport,
668
+ fix: transport === "http" ? "Use an absolute http:// or https:// URL." : "Use an absolute ws:// or wss:// URL."
669
+ }
670
+ );
671
+ }
672
+ }
673
+
674
+ // src/wire-sync-event-forwarding.ts
675
+ var SYNC_EVENT_TYPES = [
676
+ "sync:connected",
677
+ "sync:disconnected",
678
+ "sync:schema-mismatch",
679
+ "sync:auth-failed",
680
+ "sync:sent",
681
+ "sync:received",
682
+ "sync:acknowledged",
683
+ "sync:apply-failed",
684
+ "sync:diagnostics",
685
+ "sync:bandwidth",
686
+ "sync:initial-sync-progress"
687
+ ];
688
+ function wireSyncEventForwarding(emitter, handler) {
689
+ for (const type of SYNC_EVENT_TYPES) {
690
+ emitter.on(type, (event) => {
691
+ handler(event);
692
+ });
693
+ }
694
+ }
695
+
696
+ // src/create-app.ts
697
+ function createApp(config) {
698
+ validateCreateAppConfig(config);
699
+ const emitter = new SimpleEventEmitter();
700
+ const mergeEngine = new MergeEngine();
701
+ if (config.onSyncEvent) {
702
+ wireSyncEventForwarding(emitter, config.onSyncEvent);
703
+ }
704
+ let store = null;
705
+ let unsubscribeSync = null;
706
+ let unsubscribeAudit = null;
707
+ const syncState = {
708
+ syncEngine: null,
709
+ syncStatusBridge: null,
710
+ authSyncCoordinator: null,
711
+ reconnectionManager: null,
712
+ connectionMonitor: null,
713
+ qualityInterval: null,
714
+ intentionalDisconnect: false,
715
+ removeOnlineListener: null
539
716
  };
717
+ const devtools = setupDevtools(config, emitter);
718
+ const queryStoreCache = new QueryStoreCache(config.store?.name ?? "kora-db");
719
+ const ready = initializeApp(config, emitter, mergeEngine).then((init) => {
720
+ store = init.store;
721
+ unsubscribeSync = init.unsubscribeSync;
722
+ unsubscribeAudit = init.unsubscribeAudit;
723
+ wireSyncLifecycleAfterReady(config, emitter, syncState, init);
724
+ });
725
+ const getStore = () => store;
726
+ const executeTransaction = createTransactionExecutor(config, ready, getStore);
540
727
  const app = {
541
728
  ready,
542
729
  events: emitter,
543
- sync: syncControl,
544
- sequences,
730
+ sync: createSyncControl({ config, ready, state: syncState }),
731
+ sequences: createSequencesAccessor(ready, getStore),
545
732
  getStore() {
546
733
  if (!store) {
547
734
  throw new Error("Store not initialized. Await app.ready before accessing the store.");
@@ -549,30 +736,23 @@ function createApp(config) {
549
736
  return store;
550
737
  },
551
738
  getSyncEngine() {
552
- return syncEngine;
739
+ return syncState.syncEngine;
740
+ },
741
+ getQueryStoreCache() {
742
+ return queryStoreCache;
553
743
  },
554
- async transaction(fn) {
744
+ transaction(fn) {
555
745
  return executeTransaction(fn);
556
746
  },
557
- async mutation(name, fn) {
747
+ mutation(name, fn) {
558
748
  return executeTransaction(fn, name);
559
749
  },
560
750
  async close() {
561
751
  await ready;
562
- intentionalDisconnect = true;
563
- if (qualityInterval !== null) {
564
- clearInterval(qualityInterval);
565
- qualityInterval = null;
566
- }
567
- reconnectionManager?.stop();
568
- if (destroyDevtoolsOverlay) {
569
- destroyDevtoolsOverlay();
570
- destroyDevtoolsOverlay = null;
571
- }
572
- if (instrumenter) {
573
- instrumenter.destroy();
574
- instrumenter = null;
575
- }
752
+ syncState.intentionalDisconnect = true;
753
+ teardownSyncLifecycle(syncState);
754
+ devtools.destroyOverlay?.();
755
+ devtools.instrumenter?.destroy();
576
756
  if (unsubscribeSync) {
577
757
  unsubscribeSync();
578
758
  unsubscribeSync = null;
@@ -581,14 +761,11 @@ function createApp(config) {
581
761
  unsubscribeAudit();
582
762
  unsubscribeAudit = null;
583
763
  }
584
- if (syncEngine) {
585
- await syncEngine.stop();
586
- syncEngine = null;
587
- }
588
- if (syncStatusBridge) {
589
- syncStatusBridge.destroy();
590
- syncStatusBridge = null;
764
+ if (syncState.syncEngine) {
765
+ await syncState.syncEngine.stop();
766
+ syncState.syncEngine = null;
591
767
  }
768
+ queryStoreCache.clear();
592
769
  if (store) {
593
770
  await store.close();
594
771
  store = null;
@@ -627,7 +804,7 @@ function createApp(config) {
627
804
  for (const collectionName of Object.keys(config.schema.collections)) {
628
805
  Object.defineProperty(app, collectionName, {
629
806
  get() {
630
- return createCollectionAccessor(collectionName, () => store);
807
+ return createCollectionAccessor(collectionName, getStore);
631
808
  },
632
809
  enumerable: true,
633
810
  configurable: false
@@ -635,174 +812,6 @@ function createApp(config) {
635
812
  }
636
813
  return app;
637
814
  }
638
- async function initializeAsync(config, emitter, mergeEngine) {
639
- const adapterType = config.store?.adapter ?? detectAdapterType();
640
- const dbName = config.store?.name ?? "kora-db";
641
- const adapter = await createAdapter(
642
- adapterType,
643
- dbName,
644
- config.store?.workerUrl,
645
- emitter,
646
- config.store?.workerResponseTimeoutMs,
647
- config.store?.sharedWorkerUrl
648
- );
649
- const authBinding = config.sync?.authClient;
650
- const authNodeId = authBinding?.resolveNodeId ? await authBinding.resolveNodeId() : void 0;
651
- let syncEngine = null;
652
- const store = new Store({
653
- schema: config.schema,
654
- adapter,
655
- emitter,
656
- dbName,
657
- nodeId: authNodeId,
658
- isolation: authNodeId ? "shared" : config.store?.isolation,
659
- ...config.sync ? { onQuerySubscribed: createSyncQuerySubscriptionHook(() => syncEngine) } : {}
660
- });
661
- await store.open();
662
- let recordConflict;
663
- const applyPipeline = new ApplyPipeline({
664
- store,
665
- mergeEngine,
666
- emitter,
667
- onMergeConflict: () => recordConflict?.()
668
- });
669
- store.setLocalMutationHandler(applyPipeline);
670
- const unsubscribeAudit = wireAuditPersistence(store, emitter);
671
- let unsubscribeSync = null;
672
- if (config.sync) {
673
- const transport = createSyncTransport(config.sync);
674
- const mergeAwareStore = new MergeAwareSyncStore(store, mergeEngine, emitter, {
675
- onMergeConflict: () => recordConflict?.()
676
- });
677
- let scopeMap = config.sync.scope ? buildScopeMap(config.schema, config.sync.scope) : void 0;
678
- if (authBinding?.resolveScopeMap) {
679
- scopeMap = await authBinding.resolveScopeMap() ?? scopeMap;
680
- }
681
- const syncAuth = authBinding?.auth ?? config.sync.auth;
682
- const encryptor = config.sync.encryption?.enabled === true ? await SyncEncryptor.create(config.sync.encryption) : void 0;
683
- syncEngine = new SyncEngine({
684
- transport,
685
- store: mergeAwareStore,
686
- config: {
687
- url: config.sync.url,
688
- transport: config.sync.transport,
689
- auth: syncAuth,
690
- batchSize: config.sync.batchSize,
691
- schemaVersion: config.sync.schemaVersion ?? config.schema.version,
692
- scopeMap,
693
- encryption: config.sync.encryption,
694
- strictHandshake: config.sync.strictHandshake,
695
- operationTransforms: config.sync.operationTransforms
696
- },
697
- emitter,
698
- queueStorage: new StoreQueueStorage(adapter),
699
- syncState: new StoreSyncStatePersistence(store, scopeMap),
700
- encryptor
701
- });
702
- recordConflict = () => syncEngine?.recordConflict();
703
- unsubscribeSync = emitter.on("operation:created", (event) => {
704
- if (syncEngine) {
705
- syncEngine.pushOperation(event.operation);
706
- }
707
- });
708
- }
709
- return {
710
- store,
711
- syncEngine,
712
- unsubscribeSync,
713
- unsubscribeAudit,
714
- authBinding: config.sync?.authClient ?? null
715
- };
716
- }
717
- function createSyncTransport(sync) {
718
- if (sync.transport === "http") {
719
- return new HttpLongPollingTransport();
720
- }
721
- return new WebSocketTransport();
722
- }
723
- function createCollectionAccessor(collectionName, getStore) {
724
- return {
725
- async insert(data) {
726
- const currentStore = getStore();
727
- if (!currentStore) {
728
- throw new Error(`Cannot mutate collection "${collectionName}" before app.ready resolves.`);
729
- }
730
- return currentStore.collection(collectionName).insert(data);
731
- },
732
- async findById(id) {
733
- const currentStore = getStore();
734
- if (!currentStore) return null;
735
- return currentStore.collection(collectionName).findById(id);
736
- },
737
- async update(id, data) {
738
- const currentStore = getStore();
739
- if (!currentStore) {
740
- throw new Error(`Cannot mutate collection "${collectionName}" before app.ready resolves.`);
741
- }
742
- return currentStore.collection(collectionName).update(id, data);
743
- },
744
- async delete(id) {
745
- const currentStore = getStore();
746
- if (!currentStore) {
747
- throw new Error(`Cannot mutate collection "${collectionName}" before app.ready resolves.`);
748
- }
749
- return currentStore.collection(collectionName).delete(id);
750
- },
751
- where(conditions) {
752
- const currentStore = getStore();
753
- if (!currentStore) {
754
- return createPendingQueryBuilder(conditions);
755
- }
756
- return currentStore.collection(collectionName).where(conditions);
757
- }
758
- };
759
- }
760
- function createPendingQueryBuilder(initialWhere) {
761
- const descriptor = {
762
- collection: "__pending__",
763
- where: { ...initialWhere },
764
- orderBy: [],
765
- limit: void 0,
766
- offset: void 0
767
- };
768
- const builder = {
769
- where(conditions) {
770
- descriptor.where = { ...descriptor.where, ...conditions };
771
- return this;
772
- },
773
- orderBy(field, direction = "asc") {
774
- descriptor.orderBy.push({ field, direction });
775
- return this;
776
- },
777
- limit(n) {
778
- descriptor.limit = n;
779
- return this;
780
- },
781
- offset(n) {
782
- descriptor.offset = n;
783
- return this;
784
- },
785
- async exec() {
786
- throw new AppNotReadyError(
787
- "Cannot execute a query before app.ready. Await app.ready or use <KoraProvider app={app}>."
788
- );
789
- },
790
- async count() {
791
- throw new AppNotReadyError(
792
- "Cannot count query results before app.ready. Await app.ready or use <KoraProvider app={app}>."
793
- );
794
- },
795
- subscribe(_callback) {
796
- throw new AppNotReadyError(
797
- "Cannot subscribe to a query before app.ready. Await app.ready or use <KoraProvider app={app}>."
798
- );
799
- },
800
- getDescriptor() {
801
- return { ...descriptor, where: { ...descriptor.where }, orderBy: [...descriptor.orderBy] };
802
- }
803
- };
804
- return builder;
805
- }
806
815
 
807
816
  // src/index.ts
808
817
  import {
@@ -814,7 +823,7 @@ import { defineSchema, migrate, t } from "@korajs/core";
814
823
  import { HybridLogicalClock } from "@korajs/core";
815
824
  import { generateUUIDv7 } from "@korajs/core";
816
825
  import { createOperation } from "@korajs/core";
817
- import { KoraError as KoraError3 } from "@korajs/core";
826
+ import { KoraError as KoraError2, AppNotReadyError as AppNotReadyError2 } from "@korajs/core";
818
827
  import { op } from "@korajs/core";
819
828
  import { SequenceManager, Store as Store2 } from "@korajs/store";
820
829
  import { TransactionContext } from "@korajs/store";
@@ -827,8 +836,9 @@ import {
827
836
  import { MergeEngine as MergeEngine2 } from "@korajs/merge";
828
837
  import { SyncEngine as SyncEngine2, WebSocketTransport as WebSocketTransport2 } from "@korajs/sync";
829
838
  export {
839
+ AppNotReadyError2 as AppNotReadyError,
830
840
  HybridLogicalClock,
831
- KoraError3 as KoraError,
841
+ KoraError2 as KoraError,
832
842
  MergeEngine2 as MergeEngine,
833
843
  SequenceManager,
834
844
  Store2 as Store,