@korajs/store 0.3.1 → 0.4.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
@@ -32,20 +32,26 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AdapterError: () => AdapterError,
34
34
  Collection: () => Collection,
35
+ InvalidStateTransitionError: () => InvalidStateTransitionError,
35
36
  PersistenceError: () => PersistenceError,
36
37
  QueryBuilder: () => QueryBuilder,
37
38
  QueryError: () => QueryError,
38
39
  RecordNotFoundError: () => RecordNotFoundError,
40
+ SequenceManager: () => SequenceManager,
39
41
  Store: () => Store,
40
42
  StoreNotOpenError: () => StoreNotOpenError,
43
+ SubscriptionBloomFilter: () => SubscriptionBloomFilter,
41
44
  SubscriptionManager: () => SubscriptionManager,
45
+ TransactionContext: () => TransactionContext,
42
46
  WorkerInitError: () => WorkerInitError,
43
47
  WorkerTimeoutError: () => WorkerTimeoutError,
44
48
  decodeRichtext: () => decodeRichtext,
45
49
  encodeRichtext: () => encodeRichtext,
46
50
  pluralize: () => pluralize,
47
51
  richtextToPlainText: () => richtextToPlainText,
48
- singularize: () => singularize
52
+ singularize: () => singularize,
53
+ validateStateTransition: () => validateStateTransition,
54
+ validateUpdateStateMachine: () => validateUpdateStateMachine
49
55
  });
50
56
  module.exports = __toCommonJS(index_exports);
51
57
 
@@ -102,10 +108,10 @@ var PersistenceError = class extends import_core.KoraError {
102
108
  };
103
109
 
104
110
  // src/store/store.ts
105
- var import_core4 = require("@korajs/core");
111
+ var import_core8 = require("@korajs/core");
106
112
 
107
113
  // src/collection/collection.ts
108
- var import_core3 = require("@korajs/core");
114
+ var import_core4 = require("@korajs/core");
109
115
 
110
116
  // src/query/sql-builder.ts
111
117
  function buildSelectQuery(descriptor, fields) {
@@ -288,7 +294,9 @@ function decodeRichtext(value) {
288
294
  if (value instanceof ArrayBuffer) {
289
295
  return new Uint8Array(value);
290
296
  }
291
- throw new Error("Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined.");
297
+ throw new Error(
298
+ "Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined."
299
+ );
292
300
  }
293
301
  function richtextToPlainText(value) {
294
302
  const encoded = encodeRichtext(value);
@@ -327,13 +335,38 @@ function deserializeRecord(row, fields) {
327
335
  }
328
336
  return result;
329
337
  }
338
+ var ATOMIC_OPS_KEY = "__kora_atomic_ops__";
339
+ var TX_ID_KEY = "__kora_tx_id__";
340
+ var MUTATION_NAME_KEY = "__kora_mutation__";
330
341
  function serializeOperation(op) {
342
+ const hasMetadata = op.transactionId !== void 0 || op.mutationName !== void 0;
343
+ let dataPayload = null;
344
+ if (op.data) {
345
+ dataPayload = { ...op.data };
346
+ if (op.atomicOps !== void 0 && Object.keys(op.atomicOps).length > 0) {
347
+ dataPayload[ATOMIC_OPS_KEY] = op.atomicOps;
348
+ }
349
+ if (op.transactionId !== void 0) {
350
+ dataPayload[TX_ID_KEY] = op.transactionId;
351
+ }
352
+ if (op.mutationName !== void 0) {
353
+ dataPayload[MUTATION_NAME_KEY] = op.mutationName;
354
+ }
355
+ } else if (hasMetadata) {
356
+ dataPayload = {};
357
+ if (op.transactionId !== void 0) {
358
+ dataPayload[TX_ID_KEY] = op.transactionId;
359
+ }
360
+ if (op.mutationName !== void 0) {
361
+ dataPayload[MUTATION_NAME_KEY] = op.mutationName;
362
+ }
363
+ }
331
364
  return {
332
365
  id: op.id,
333
366
  node_id: op.nodeId,
334
367
  type: op.type,
335
368
  record_id: op.recordId,
336
- data: op.data ? JSON.stringify(op.data) : null,
369
+ data: dataPayload ? JSON.stringify(dataPayload) : null,
337
370
  previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
338
371
  timestamp: import_core2.HybridLogicalClock.serialize(op.timestamp),
339
372
  sequence_number: op.sequenceNumber,
@@ -342,6 +375,24 @@ function serializeOperation(op) {
342
375
  };
343
376
  }
344
377
  function deserializeOperation(row) {
378
+ let data = null;
379
+ let atomicOps;
380
+ let transactionId;
381
+ let mutationName;
382
+ if (row.data) {
383
+ const parsed = JSON.parse(row.data);
384
+ if (ATOMIC_OPS_KEY in parsed) {
385
+ atomicOps = parsed[ATOMIC_OPS_KEY];
386
+ }
387
+ if (TX_ID_KEY in parsed) {
388
+ transactionId = parsed[TX_ID_KEY];
389
+ }
390
+ if (MUTATION_NAME_KEY in parsed) {
391
+ mutationName = parsed[MUTATION_NAME_KEY];
392
+ }
393
+ const { [ATOMIC_OPS_KEY]: _a, [TX_ID_KEY]: _t, [MUTATION_NAME_KEY]: _m, ...rest } = parsed;
394
+ data = Object.keys(rest).length > 0 ? rest : null;
395
+ }
345
396
  return {
346
397
  id: row.id,
347
398
  nodeId: row.node_id,
@@ -349,12 +400,15 @@ function deserializeOperation(row) {
349
400
  collection: "",
350
401
  // Collection name is derived from the table name by the caller
351
402
  recordId: row.record_id,
352
- data: row.data ? JSON.parse(row.data) : null,
403
+ data,
353
404
  previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
354
405
  timestamp: import_core2.HybridLogicalClock.deserialize(row.timestamp),
355
406
  sequenceNumber: row.sequence_number,
356
407
  causalDeps: JSON.parse(row.causal_deps),
357
- schemaVersion: row.schema_version
408
+ schemaVersion: row.schema_version,
409
+ ...atomicOps !== void 0 ? { atomicOps } : {},
410
+ ...transactionId !== void 0 ? { transactionId } : {},
411
+ ...mutationName !== void 0 ? { mutationName } : {}
358
412
  };
359
413
  }
360
414
  function deserializeOperationWithCollection(row, collection) {
@@ -392,9 +446,91 @@ function deserializeValue(value, descriptor) {
392
446
  }
393
447
  }
394
448
 
449
+ // src/state-machine/state-validator.ts
450
+ var import_core3 = require("@korajs/core");
451
+ var InvalidStateTransitionError = class extends import_core3.KoraError {
452
+ constructor(collection, recordId, field, fromState, toState, allowedStates) {
453
+ super(
454
+ `Invalid state transition in collection "${collection}": cannot transition field "${field}" from "${fromState}" to "${toState}". Allowed transitions from "${fromState}": ${allowedStates.length > 0 ? allowedStates.join(", ") : "(none -- terminal state)"}`,
455
+ "INVALID_STATE_TRANSITION",
456
+ { collection, recordId, field, fromState, toState, allowedStates }
457
+ );
458
+ this.collection = collection;
459
+ this.recordId = recordId;
460
+ this.field = field;
461
+ this.fromState = fromState;
462
+ this.toState = toState;
463
+ this.allowedStates = allowedStates;
464
+ this.name = "InvalidStateTransitionError";
465
+ }
466
+ collection;
467
+ recordId;
468
+ field;
469
+ fromState;
470
+ toState;
471
+ allowedStates;
472
+ };
473
+ function validateStateTransition(collectionName, recordId, stateMachine, currentState, newState) {
474
+ if (currentState === null) {
475
+ return { valid: true, allowedStates: [] };
476
+ }
477
+ if (currentState === newState) {
478
+ return { valid: true, allowedStates: stateMachine.transitions[currentState] ?? [] };
479
+ }
480
+ const constraint = {
481
+ field: stateMachine.field,
482
+ collection: collectionName,
483
+ transitions: stateMachine.transitions
484
+ };
485
+ const transitionResult = (0, import_core3.validateTransition)(constraint, currentState, newState);
486
+ if (transitionResult.valid) {
487
+ return { valid: true, allowedStates: transitionResult.allowedTargets };
488
+ }
489
+ const allowedStates = transitionResult.allowedTargets;
490
+ if (stateMachine.onInvalidTransition === "reject") {
491
+ throw new InvalidStateTransitionError(
492
+ collectionName,
493
+ recordId,
494
+ stateMachine.field,
495
+ currentState,
496
+ newState,
497
+ allowedStates
498
+ );
499
+ }
500
+ return { valid: false, allowedStates };
501
+ }
502
+ function validateUpdateStateMachine(collectionName, recordId, collectionDef, currentRecord, updateData) {
503
+ const stateMachine = collectionDef.stateMachine;
504
+ if (stateMachine === void 0) {
505
+ return updateData;
506
+ }
507
+ const stateField = stateMachine.field;
508
+ if (!(stateField in updateData)) {
509
+ return updateData;
510
+ }
511
+ const currentState = currentRecord[stateField];
512
+ const newState = updateData[stateField];
513
+ if (typeof currentState !== "string" || typeof newState !== "string") {
514
+ return updateData;
515
+ }
516
+ const result = validateStateTransition(
517
+ collectionName,
518
+ recordId,
519
+ stateMachine,
520
+ currentState,
521
+ newState
522
+ );
523
+ if (result.valid) {
524
+ return updateData;
525
+ }
526
+ const filtered = { ...updateData };
527
+ delete filtered[stateField];
528
+ return filtered;
529
+ }
530
+
395
531
  // src/collection/collection.ts
396
532
  var Collection = class {
397
- constructor(name, definition, schema, adapter, clock, nodeId, getSequenceNumber, onMutation) {
533
+ constructor(name, definition, schema, adapter, clock, nodeId, getSequenceNumber, onMutation, relationEnforcer) {
398
534
  this.name = name;
399
535
  this.definition = definition;
400
536
  this.schema = schema;
@@ -403,6 +539,7 @@ var Collection = class {
403
539
  this.nodeId = nodeId;
404
540
  this.getSequenceNumber = getSequenceNumber;
405
541
  this.onMutation = onMutation;
542
+ this.relationEnforcer = relationEnforcer ?? null;
406
543
  }
407
544
  name;
408
545
  definition;
@@ -412,6 +549,7 @@ var Collection = class {
412
549
  nodeId;
413
550
  getSequenceNumber;
414
551
  onMutation;
552
+ relationEnforcer;
415
553
  /**
416
554
  * Insert a new record into the collection.
417
555
  * Generates a UUID v7 for the id, validates data, and persists atomically.
@@ -420,8 +558,8 @@ var Collection = class {
420
558
  * @returns The inserted record with id, createdAt, updatedAt
421
559
  */
422
560
  async insert(data) {
423
- const validated = (0, import_core3.validateRecord)(this.name, this.definition, data, "insert");
424
- const recordId = (0, import_core3.generateUUIDv7)();
561
+ const validated = (0, import_core4.validateRecord)(this.name, this.definition, data, "insert");
562
+ const recordId = (0, import_core4.generateUUIDv7)();
425
563
  const now = Date.now();
426
564
  for (const [fieldName, descriptor] of Object.entries(this.definition.fields)) {
427
565
  if (descriptor.auto && descriptor.kind === "timestamp") {
@@ -429,7 +567,7 @@ var Collection = class {
429
567
  }
430
568
  }
431
569
  const sequenceNumber = this.getSequenceNumber();
432
- const operation = await (0, import_core3.createOperation)(
570
+ const operation = await (0, import_core4.createOperation)(
433
571
  {
434
572
  nodeId: this.nodeId,
435
573
  type: "insert",
@@ -501,29 +639,54 @@ var Collection = class {
501
639
  if (!currentRow) {
502
640
  throw new RecordNotFoundError(this.name, id);
503
641
  }
504
- const validated = (0, import_core3.validateRecord)(this.name, this.definition, data, "update");
642
+ let validated = (0, import_core4.validateRecord)(this.name, this.definition, data, "update");
505
643
  const now = Date.now();
506
- const previousData = {};
507
644
  const currentRecord = deserializeRecord(currentRow, this.definition.fields);
645
+ validated = validateUpdateStateMachine(
646
+ this.name,
647
+ id,
648
+ this.definition,
649
+ currentRecord,
650
+ validated
651
+ );
652
+ if (Object.keys(validated).length === 0) {
653
+ const fullRecord = await this.findById(id);
654
+ if (!fullRecord) {
655
+ throw new RecordNotFoundError(this.name, id);
656
+ }
657
+ return fullRecord;
658
+ }
659
+ const previousData = {};
660
+ const resolvedData = {};
661
+ const atomicOps = {};
508
662
  for (const key of Object.keys(validated)) {
663
+ const value = validated[key];
509
664
  previousData[key] = currentRecord[key];
665
+ if ((0, import_core4.isAtomicOp)(value)) {
666
+ resolvedData[key] = (0, import_core4.resolveAtomicOp)(currentRecord[key], value);
667
+ atomicOps[key] = (0, import_core4.toAtomicOp)(value);
668
+ } else {
669
+ resolvedData[key] = value;
670
+ }
510
671
  }
672
+ const hasAtomicOps = Object.keys(atomicOps).length > 0;
511
673
  const sequenceNumber = this.getSequenceNumber();
512
- const operation = await (0, import_core3.createOperation)(
674
+ const operation = await (0, import_core4.createOperation)(
513
675
  {
514
676
  nodeId: this.nodeId,
515
677
  type: "update",
516
678
  collection: this.name,
517
679
  recordId: id,
518
- data: { ...validated },
680
+ data: { ...resolvedData },
519
681
  previousData,
520
682
  sequenceNumber,
521
683
  causalDeps: [],
522
- schemaVersion: this.schema.version
684
+ schemaVersion: this.schema.version,
685
+ ...hasAtomicOps ? { atomicOps } : {}
523
686
  },
524
687
  this.clock
525
688
  );
526
- const serializedChanges = serializeRecord(validated, this.definition.fields);
689
+ const serializedChanges = serializeRecord(resolvedData, this.definition.fields);
527
690
  const updateQuery = buildUpdateQuery(this.name, id, {
528
691
  ...serializedChanges,
529
692
  _updated_at: now
@@ -551,8 +714,19 @@ var Collection = class {
551
714
  /**
552
715
  * Soft-delete a record by its ID.
553
716
  *
717
+ * When a RelationEnforcer is configured, referential integrity policies
718
+ * are enforced within the same transaction:
719
+ * - **cascade**: Recursively deletes all referencing records
720
+ * - **set-null**: Sets foreign keys to null on referencing records
721
+ * - **restrict**: Throws ReferentialIntegrityError if references exist
722
+ * - **no-action**: Does nothing (dangling references are left)
723
+ *
724
+ * All cascaded operations are created atomically within the same transaction
725
+ * and share causal dependencies with the original delete.
726
+ *
554
727
  * @param id - The record ID to delete
555
728
  * @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
729
+ * @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
556
730
  */
557
731
  async delete(id) {
558
732
  const currentRows = await this.adapter.query(
@@ -564,7 +738,7 @@ var Collection = class {
564
738
  }
565
739
  const now = Date.now();
566
740
  const sequenceNumber = this.getSequenceNumber();
567
- const operation = await (0, import_core3.createOperation)(
741
+ const operation = await (0, import_core4.createOperation)(
568
742
  {
569
743
  nodeId: this.nodeId,
570
744
  type: "delete",
@@ -584,7 +758,17 @@ var Collection = class {
584
758
  `_kora_ops_${this.name}`,
585
759
  opRow
586
760
  );
761
+ const cascadedOps = [];
587
762
  await this.adapter.transaction(async (tx) => {
763
+ if (this.relationEnforcer) {
764
+ const enforcementResult = await this.relationEnforcer.enforceDelete(
765
+ this.name,
766
+ id,
767
+ tx,
768
+ [operation.id]
769
+ );
770
+ cascadedOps.push(...enforcementResult.operations);
771
+ }
588
772
  await tx.execute(deleteQuery.sql, deleteQuery.params);
589
773
  await tx.execute(opInsert.sql, opInsert.params);
590
774
  await tx.execute(
@@ -593,6 +777,9 @@ var Collection = class {
593
777
  );
594
778
  });
595
779
  this.onMutation(this.name, operation);
780
+ for (const cascadedOp of cascadedOps) {
781
+ this.onMutation(cascadedOp.collection, cascadedOp);
782
+ }
596
783
  }
597
784
  /** Get the collection name */
598
785
  getName() {
@@ -897,12 +1084,500 @@ var QueryError2 = class extends Error {
897
1084
  }
898
1085
  };
899
1086
 
1087
+ // src/relations/relation-enforcer.ts
1088
+ var import_core5 = require("@korajs/core");
1089
+
1090
+ // src/relations/relation-lookup.ts
1091
+ function buildRelationLookup(schema) {
1092
+ const lookup = /* @__PURE__ */ new Map();
1093
+ for (const [relationName, relation] of Object.entries(schema.relations)) {
1094
+ const targetCollection = relation.to;
1095
+ const existing = lookup.get(targetCollection) ?? [];
1096
+ existing.push({
1097
+ relationName,
1098
+ sourceCollection: relation.from,
1099
+ foreignKeyField: relation.field,
1100
+ onDelete: relation.onDelete,
1101
+ relation
1102
+ });
1103
+ lookup.set(targetCollection, existing);
1104
+ }
1105
+ return lookup;
1106
+ }
1107
+ function getIncomingRelations(lookup, collection) {
1108
+ return lookup.get(collection) ?? [];
1109
+ }
1110
+
1111
+ // src/relations/relation-enforcer.ts
1112
+ var ReferentialIntegrityError = class extends import_core5.KoraError {
1113
+ constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
1114
+ super(
1115
+ `Cannot delete record "${recordId}" from "${collection}": ${referencingCount} record(s) in "${referencingCollection}" reference it via relation "${relationName}" with onDelete: 'restrict'. Delete or reassign the referencing records first.`,
1116
+ "REFERENTIAL_INTEGRITY",
1117
+ {
1118
+ collection,
1119
+ recordId,
1120
+ referencingCollection,
1121
+ relationName,
1122
+ referencingCount
1123
+ }
1124
+ );
1125
+ this.name = "ReferentialIntegrityError";
1126
+ }
1127
+ };
1128
+ var RelationEnforcer = class {
1129
+ lookup;
1130
+ schema;
1131
+ adapter;
1132
+ clock;
1133
+ nodeId;
1134
+ getSequenceNumber;
1135
+ constructor(config) {
1136
+ this.schema = config.schema;
1137
+ this.adapter = config.adapter;
1138
+ this.clock = config.clock;
1139
+ this.nodeId = config.nodeId;
1140
+ this.getSequenceNumber = config.getSequenceNumber;
1141
+ this.lookup = buildRelationLookup(config.schema);
1142
+ }
1143
+ /**
1144
+ * Enforce referential integrity after deleting a record.
1145
+ *
1146
+ * Must be called within a transaction. The transaction handle is used
1147
+ * for all cascaded writes to ensure atomicity.
1148
+ *
1149
+ * @param collection - The collection the deleted record belongs to
1150
+ * @param recordId - The ID of the deleted record
1151
+ * @param tx - The active transaction handle
1152
+ * @param causalDeps - Causal dependencies for generated operations
1153
+ * @returns All additional operations created as side effects
1154
+ * @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
1155
+ */
1156
+ async enforceDelete(collection, recordId, tx, causalDeps) {
1157
+ const incomingRelations = getIncomingRelations(this.lookup, collection);
1158
+ if (incomingRelations.length === 0) {
1159
+ return { operations: [] };
1160
+ }
1161
+ const allOperations = [];
1162
+ const sortedRelations = [...incomingRelations].sort(
1163
+ (a, b) => a.relationName.localeCompare(b.relationName)
1164
+ );
1165
+ for (const incoming of sortedRelations) {
1166
+ const ops = await this.enforceRelation(incoming, recordId, tx, causalDeps);
1167
+ allOperations.push(...ops);
1168
+ }
1169
+ return { operations: allOperations };
1170
+ }
1171
+ /**
1172
+ * Enforce a single relation's onDelete policy.
1173
+ */
1174
+ async enforceRelation(incoming, deletedRecordId, tx, causalDeps) {
1175
+ switch (incoming.onDelete) {
1176
+ case "cascade":
1177
+ return this.enforceCascade(incoming, deletedRecordId, tx, causalDeps);
1178
+ case "set-null":
1179
+ return this.enforceSetNull(incoming, deletedRecordId, tx, causalDeps);
1180
+ case "restrict":
1181
+ return this.enforceRestrict(incoming, deletedRecordId, tx);
1182
+ case "no-action":
1183
+ return [];
1184
+ }
1185
+ }
1186
+ /**
1187
+ * Cascade: delete all records in the source collection that reference
1188
+ * the deleted record, then recursively cascade those deletes.
1189
+ */
1190
+ async enforceCascade(incoming, deletedRecordId, tx, causalDeps) {
1191
+ const { sourceCollection, foreignKeyField } = incoming;
1192
+ const referencingRows = await tx.query(
1193
+ `SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1194
+ [deletedRecordId]
1195
+ );
1196
+ if (referencingRows.length === 0) {
1197
+ return [];
1198
+ }
1199
+ const operations = [];
1200
+ for (const row of referencingRows) {
1201
+ const now = Date.now();
1202
+ const sequenceNumber = this.getSequenceNumber();
1203
+ const operation = await (0, import_core5.createOperation)(
1204
+ {
1205
+ nodeId: this.nodeId,
1206
+ type: "delete",
1207
+ collection: sourceCollection,
1208
+ recordId: row.id,
1209
+ data: null,
1210
+ previousData: null,
1211
+ sequenceNumber,
1212
+ causalDeps: [...causalDeps],
1213
+ schemaVersion: this.schema.version
1214
+ },
1215
+ this.clock
1216
+ );
1217
+ const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
1218
+ await tx.execute(deleteQuery.sql, deleteQuery.params);
1219
+ const opRow = serializeOperation(operation);
1220
+ const opInsert = buildInsertQuery(
1221
+ `_kora_ops_${sourceCollection}`,
1222
+ opRow
1223
+ );
1224
+ await tx.execute(opInsert.sql, opInsert.params);
1225
+ await tx.execute(
1226
+ "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1227
+ [this.nodeId, sequenceNumber]
1228
+ );
1229
+ operations.push(operation);
1230
+ const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
1231
+ operations.push(...cascadeResult.operations);
1232
+ }
1233
+ return operations;
1234
+ }
1235
+ /**
1236
+ * Set-null: update all referencing records to set the foreign key to null.
1237
+ */
1238
+ async enforceSetNull(incoming, deletedRecordId, tx, causalDeps) {
1239
+ const { sourceCollection, foreignKeyField } = incoming;
1240
+ const collectionDef = this.schema.collections[sourceCollection];
1241
+ if (!collectionDef) {
1242
+ return [];
1243
+ }
1244
+ const referencingRows = await tx.query(
1245
+ `SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1246
+ [deletedRecordId]
1247
+ );
1248
+ if (referencingRows.length === 0) {
1249
+ return [];
1250
+ }
1251
+ const operations = [];
1252
+ for (const row of referencingRows) {
1253
+ const now = Date.now();
1254
+ const sequenceNumber = this.getSequenceNumber();
1255
+ const updateData = { [foreignKeyField]: null };
1256
+ const previousData = { [foreignKeyField]: deletedRecordId };
1257
+ const operation = await (0, import_core5.createOperation)(
1258
+ {
1259
+ nodeId: this.nodeId,
1260
+ type: "update",
1261
+ collection: sourceCollection,
1262
+ recordId: row.id,
1263
+ data: { ...updateData },
1264
+ previousData,
1265
+ sequenceNumber,
1266
+ causalDeps: [...causalDeps],
1267
+ schemaVersion: this.schema.version
1268
+ },
1269
+ this.clock
1270
+ );
1271
+ const serializedChanges = serializeRecord(updateData, collectionDef.fields);
1272
+ const updateQuery = buildUpdateQuery(sourceCollection, row.id, {
1273
+ ...serializedChanges,
1274
+ _updated_at: now
1275
+ });
1276
+ await tx.execute(updateQuery.sql, updateQuery.params);
1277
+ const opRow = serializeOperation(operation);
1278
+ const opInsert = buildInsertQuery(
1279
+ `_kora_ops_${sourceCollection}`,
1280
+ opRow
1281
+ );
1282
+ await tx.execute(opInsert.sql, opInsert.params);
1283
+ await tx.execute(
1284
+ "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1285
+ [this.nodeId, sequenceNumber]
1286
+ );
1287
+ operations.push(operation);
1288
+ }
1289
+ return operations;
1290
+ }
1291
+ /**
1292
+ * Restrict: refuse the delete if any referencing records exist.
1293
+ */
1294
+ async enforceRestrict(incoming, deletedRecordId, tx) {
1295
+ const { sourceCollection, foreignKeyField, relationName } = incoming;
1296
+ const countRows = await tx.query(
1297
+ `SELECT COUNT(*) as cnt FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1298
+ [deletedRecordId]
1299
+ );
1300
+ const count = countRows[0]?.cnt ?? 0;
1301
+ if (count > 0) {
1302
+ const targetCollection = incoming.relation.to;
1303
+ throw new ReferentialIntegrityError(
1304
+ targetCollection,
1305
+ deletedRecordId,
1306
+ sourceCollection,
1307
+ relationName,
1308
+ count
1309
+ );
1310
+ }
1311
+ return [];
1312
+ }
1313
+ /**
1314
+ * Get the relation lookup map for external use (e.g., by the merge engine).
1315
+ */
1316
+ getRelationLookup() {
1317
+ return this.lookup;
1318
+ }
1319
+ };
1320
+
1321
+ // src/sequences/sequence-manager.ts
1322
+ var import_core6 = require("@korajs/core");
1323
+ var SequenceManager = class {
1324
+ adapter;
1325
+ nodeId;
1326
+ constructor(adapter, nodeId) {
1327
+ this.adapter = adapter;
1328
+ this.nodeId = nodeId;
1329
+ }
1330
+ /**
1331
+ * Get the next value in a sequence, atomically incrementing the counter.
1332
+ *
1333
+ * @param name - The sequence name (e.g., 'receipt', 'invoice')
1334
+ * @param config - Optional configuration for scope, format, and starting value
1335
+ * @returns The formatted sequence value
1336
+ */
1337
+ async next(name, config) {
1338
+ const scope = config?.scope ?? "";
1339
+ const startAt = config?.startAt ?? 1;
1340
+ const format = config?.format ?? (0, import_core6.defaultSequenceFormat)(name);
1341
+ let counter = 0;
1342
+ await this.adapter.transaction(async (tx) => {
1343
+ const rows = await tx.query(
1344
+ "SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1345
+ [name, scope, this.nodeId]
1346
+ );
1347
+ if (rows.length > 0) {
1348
+ const row = rows[0];
1349
+ counter = row.counter + 1;
1350
+ await tx.execute(
1351
+ "UPDATE _kora_sequences SET counter = ? WHERE name = ? AND scope = ? AND node_id = ?",
1352
+ [counter, name, scope, this.nodeId]
1353
+ );
1354
+ } else {
1355
+ counter = startAt;
1356
+ await tx.execute(
1357
+ "INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
1358
+ [name, scope, this.nodeId, counter]
1359
+ );
1360
+ }
1361
+ });
1362
+ return (0, import_core6.formatSequenceValue)(format, counter, this.nodeId);
1363
+ }
1364
+ /**
1365
+ * Get the current counter value without incrementing.
1366
+ *
1367
+ * @param name - The sequence name
1368
+ * @param config - Optional scope
1369
+ * @returns The current counter value, or 0 if the sequence has never been used
1370
+ */
1371
+ async current(name, config) {
1372
+ const scope = config?.scope ?? "";
1373
+ const rows = await this.adapter.query(
1374
+ "SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1375
+ [name, scope, this.nodeId]
1376
+ );
1377
+ if (rows.length > 0) {
1378
+ return rows[0].counter;
1379
+ }
1380
+ return 0;
1381
+ }
1382
+ /**
1383
+ * Reset a sequence counter.
1384
+ *
1385
+ * @param name - The sequence name
1386
+ * @param config - Optional scope and target value
1387
+ */
1388
+ async reset(name, config) {
1389
+ const scope = config?.scope ?? "";
1390
+ const to = config?.to ?? 0;
1391
+ await this.adapter.execute(
1392
+ "DELETE FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1393
+ [name, scope, this.nodeId]
1394
+ );
1395
+ if (to > 0) {
1396
+ await this.adapter.execute(
1397
+ "INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
1398
+ [name, scope, this.nodeId, to]
1399
+ );
1400
+ }
1401
+ }
1402
+ };
1403
+
1404
+ // src/subscription/bloom-filter.ts
1405
+ var FNV_OFFSET_BASIS = 2166136261;
1406
+ var FNV_PRIME = 16777619;
1407
+ function fnv1a32(input) {
1408
+ let hash = FNV_OFFSET_BASIS;
1409
+ for (let i = 0; i < input.length; i++) {
1410
+ hash ^= input.charCodeAt(i);
1411
+ hash = Math.imul(hash, FNV_PRIME);
1412
+ }
1413
+ return hash >>> 0;
1414
+ }
1415
+ function optimalBitCount(expectedItems, falsePositiveRate) {
1416
+ if (expectedItems <= 0) return 32;
1417
+ if (falsePositiveRate <= 0 || falsePositiveRate >= 1) return 32;
1418
+ const ln2Squared = Math.LN2 * Math.LN2;
1419
+ const rawBits = -(expectedItems * Math.log(falsePositiveRate)) / ln2Squared;
1420
+ const aligned = Math.ceil(rawBits / 32) * 32;
1421
+ return Math.max(32, aligned);
1422
+ }
1423
+ function optimalHashCount(bitCount, expectedItems) {
1424
+ if (expectedItems <= 0) return 1;
1425
+ const raw = bitCount / expectedItems * Math.LN2;
1426
+ return Math.max(1, Math.min(30, Math.round(raw)));
1427
+ }
1428
+ var SubscriptionBloomFilter = class {
1429
+ bits;
1430
+ bitCount;
1431
+ hashCount;
1432
+ itemCount = 0;
1433
+ constructor(expectedItems, falsePositiveRate = 0.01) {
1434
+ this.bitCount = optimalBitCount(expectedItems, falsePositiveRate);
1435
+ this.hashCount = optimalHashCount(this.bitCount, expectedItems);
1436
+ this.bits = new Uint32Array(this.bitCount / 32);
1437
+ }
1438
+ /**
1439
+ * Add a collection (and optional field) to the bloom filter.
1440
+ *
1441
+ * @param collection - Collection name (e.g., "todos")
1442
+ * @param field - Optional field name (e.g., "completed")
1443
+ */
1444
+ add(collection, field) {
1445
+ const key = field !== void 0 ? `${collection}:${field}` : collection;
1446
+ const positions = this.getPositions(key);
1447
+ for (const pos of positions) {
1448
+ const wordIndex = pos >>> 5;
1449
+ const bitIndex = pos & 31;
1450
+ const current = this.bits[wordIndex] ?? 0;
1451
+ this.bits[wordIndex] = current | 1 << bitIndex;
1452
+ }
1453
+ this.itemCount++;
1454
+ }
1455
+ /**
1456
+ * Check if a collection (and optional field) might be in the filter.
1457
+ *
1458
+ * A return value of `false` means the item is DEFINITELY NOT in the filter.
1459
+ * A return value of `true` means the item MIGHT be in the filter (possible false positive).
1460
+ *
1461
+ * @param collection - Collection name to check
1462
+ * @param field - Optional field name to check
1463
+ * @returns false = definitely absent, true = possibly present
1464
+ */
1465
+ mightContain(collection, field) {
1466
+ const key = field !== void 0 ? `${collection}:${field}` : collection;
1467
+ const positions = this.getPositions(key);
1468
+ for (const pos of positions) {
1469
+ const wordIndex = pos >>> 5;
1470
+ const bitIndex = pos & 31;
1471
+ if (((this.bits[wordIndex] ?? 0) & 1 << bitIndex) === 0) {
1472
+ return false;
1473
+ }
1474
+ }
1475
+ return true;
1476
+ }
1477
+ /**
1478
+ * Reset the bloom filter, clearing all bits and the item count.
1479
+ */
1480
+ clear() {
1481
+ this.bits.fill(0);
1482
+ this.itemCount = 0;
1483
+ }
1484
+ /**
1485
+ * Estimate the current false positive rate based on the number of items inserted.
1486
+ *
1487
+ * Formula: (1 - e^(-kn/m))^k
1488
+ * where k = hash count, n = item count, m = bit count.
1489
+ *
1490
+ * @returns Estimated false positive rate as a number between 0 and 1
1491
+ */
1492
+ estimatedFalsePositiveRate() {
1493
+ if (this.itemCount === 0) return 0;
1494
+ const exponent = -(this.hashCount * this.itemCount) / this.bitCount;
1495
+ return (1 - Math.exp(exponent)) ** this.hashCount;
1496
+ }
1497
+ /**
1498
+ * @returns The number of items that have been added to the filter
1499
+ */
1500
+ getItemCount() {
1501
+ return this.itemCount;
1502
+ }
1503
+ /**
1504
+ * @returns The total number of bits in the filter
1505
+ */
1506
+ getBitCount() {
1507
+ return this.bitCount;
1508
+ }
1509
+ /**
1510
+ * @returns The number of hash functions used
1511
+ */
1512
+ getHashCount() {
1513
+ return this.hashCount;
1514
+ }
1515
+ /**
1516
+ * Count the number of bits currently set to 1.
1517
+ * Uses Brian Kernighan's algorithm: each iteration clears the lowest set bit,
1518
+ * so the loop runs exactly as many times as there are set bits.
1519
+ *
1520
+ * @returns Number of bits set to 1
1521
+ */
1522
+ getSetBitCount() {
1523
+ let count = 0;
1524
+ for (let i = 0; i < this.bits.length; i++) {
1525
+ let word = this.bits[i] ?? 0;
1526
+ while (word !== 0) {
1527
+ word &= word - 1;
1528
+ count++;
1529
+ }
1530
+ }
1531
+ return count;
1532
+ }
1533
+ /**
1534
+ * Compute k bit positions for a given key using Kirsch-Mitzenmacker double hashing.
1535
+ *
1536
+ * Instead of computing k independent hash functions, we compute two base hashes
1537
+ * (h1 and h2) and derive the rest as: h_i = (h1 + i * h2) mod m.
1538
+ * This technique is proven to have the same asymptotic false positive rate as
1539
+ * k independent hash functions.
1540
+ *
1541
+ * We derive h1 and h2 from a single FNV-1a hash by splitting and mixing:
1542
+ * h1 = fnv1a(key), h2 = fnv1a(key + "\0salt") to ensure independence.
1543
+ */
1544
+ getPositions(key) {
1545
+ const h1 = fnv1a32(key);
1546
+ const h2 = fnv1a32(`${key}\0bloom`);
1547
+ const positions = new Array(this.hashCount);
1548
+ for (let i = 0; i < this.hashCount; i++) {
1549
+ positions[i] = (h1 + Math.imul(i, h2) >>> 0) % this.bitCount;
1550
+ }
1551
+ return positions;
1552
+ }
1553
+ };
1554
+
900
1555
  // src/subscription/subscription-manager.ts
901
1556
  var nextSubId = 0;
1557
+ var DEFAULT_BLOOM_THRESHOLD = 100;
1558
+ var DEFAULT_BLOOM_EXPECTED_ITEMS = 500;
1559
+ var DEFAULT_BLOOM_FALSE_POSITIVE_RATE = 0.01;
902
1560
  var SubscriptionManager = class {
903
1561
  subscriptions = /* @__PURE__ */ new Map();
904
1562
  pendingCollections = /* @__PURE__ */ new Set();
905
1563
  flushScheduled = false;
1564
+ // Bloom filter state
1565
+ bloomFilter = null;
1566
+ bloomDirty = false;
1567
+ bloomThreshold;
1568
+ bloomExpectedItems;
1569
+ bloomFalsePositiveRate;
1570
+ // Performance stats
1571
+ totalChecks = 0;
1572
+ bloomFilterHits = 0;
1573
+ bloomFilterMisses = 0;
1574
+ falsePositives = 0;
1575
+ totalCheckTimeMs = 0;
1576
+ constructor(options) {
1577
+ this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
1578
+ this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
1579
+ this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
1580
+ }
906
1581
  /**
907
1582
  * Register a new subscription.
908
1583
  *
@@ -921,8 +1596,10 @@ var SubscriptionManager = class {
921
1596
  lastResults: []
922
1597
  };
923
1598
  this.subscriptions.set(id, subscription);
1599
+ this.bloomDirty = true;
924
1600
  return () => {
925
1601
  this.subscriptions.delete(id);
1602
+ this.bloomDirty = true;
926
1603
  };
927
1604
  }
928
1605
  /**
@@ -942,6 +1619,7 @@ var SubscriptionManager = class {
942
1619
  lastResults: []
943
1620
  };
944
1621
  this.subscriptions.set(id, subscription);
1622
+ this.bloomDirty = true;
945
1623
  executeFn().then((results) => {
946
1624
  if (this.subscriptions.has(id)) {
947
1625
  subscription.lastResults = results;
@@ -950,6 +1628,7 @@ var SubscriptionManager = class {
950
1628
  });
951
1629
  return () => {
952
1630
  this.subscriptions.delete(id);
1631
+ this.bloomDirty = true;
953
1632
  };
954
1633
  }
955
1634
  /**
@@ -969,19 +1648,7 @@ var SubscriptionManager = class {
969
1648
  const collections = new Set(this.pendingCollections);
970
1649
  this.pendingCollections.clear();
971
1650
  this.flushScheduled = false;
972
- const affected = [];
973
- for (const sub of this.subscriptions.values()) {
974
- if (collections.has(sub.descriptor.collection)) {
975
- affected.push(sub);
976
- } else if (sub.descriptor.includeCollections) {
977
- for (const incCol of sub.descriptor.includeCollections) {
978
- if (collections.has(incCol)) {
979
- affected.push(sub);
980
- break;
981
- }
982
- }
983
- }
984
- }
1651
+ const affected = this.findAffectedSubscriptions(collections);
985
1652
  for (const sub of affected) {
986
1653
  try {
987
1654
  const newResults = await sub.executeFn();
@@ -1000,11 +1667,118 @@ var SubscriptionManager = class {
1000
1667
  this.subscriptions.clear();
1001
1668
  this.pendingCollections.clear();
1002
1669
  this.flushScheduled = false;
1670
+ this.bloomFilter = null;
1671
+ this.bloomDirty = false;
1672
+ this.totalChecks = 0;
1673
+ this.bloomFilterHits = 0;
1674
+ this.bloomFilterMisses = 0;
1675
+ this.falsePositives = 0;
1676
+ this.totalCheckTimeMs = 0;
1003
1677
  }
1004
1678
  /** Number of active subscriptions (for testing/debugging) */
1005
1679
  get size() {
1006
1680
  return this.subscriptions.size;
1007
1681
  }
1682
+ /**
1683
+ * Get performance statistics for monitoring subscription checking efficiency.
1684
+ * Useful for DevTools integration and performance tuning.
1685
+ */
1686
+ getStats() {
1687
+ return {
1688
+ totalChecks: this.totalChecks,
1689
+ bloomFilterHits: this.bloomFilterHits,
1690
+ bloomFilterMisses: this.bloomFilterMisses,
1691
+ falsePositives: this.falsePositives,
1692
+ averageCheckTimeMs: this.totalChecks > 0 ? this.totalCheckTimeMs / this.totalChecks : 0,
1693
+ bloomFilterActive: this.isBloomActive(),
1694
+ subscriptionCount: this.subscriptions.size
1695
+ };
1696
+ }
1697
+ /**
1698
+ * Check if bloom filter is currently active.
1699
+ * Active when subscription count meets or exceeds the threshold.
1700
+ */
1701
+ isBloomActive() {
1702
+ return this.subscriptions.size >= this.bloomThreshold;
1703
+ }
1704
+ /**
1705
+ * Find subscriptions affected by mutations to the given collections.
1706
+ * Uses two-level checking when bloom filter is active:
1707
+ *
1708
+ * Level 1: Bloom filter pre-check -- if no subscription depends on any
1709
+ * of the mutated collections, skip everything (O(k) per collection).
1710
+ *
1711
+ * Level 2: Precise check -- linear scan of subscriptions, matching
1712
+ * against the mutated collections.
1713
+ */
1714
+ findAffectedSubscriptions(collections) {
1715
+ const startTime = performance.now();
1716
+ this.totalChecks++;
1717
+ const useBloom = this.isBloomActive();
1718
+ if (useBloom) {
1719
+ if (this.bloomDirty || this.bloomFilter === null) {
1720
+ this.rebuildBloomFilter();
1721
+ }
1722
+ const filter = this.bloomFilter;
1723
+ if (filter !== null) {
1724
+ let anyPossibleMatch = false;
1725
+ for (const col of collections) {
1726
+ if (filter.mightContain(col)) {
1727
+ anyPossibleMatch = true;
1728
+ break;
1729
+ }
1730
+ }
1731
+ if (!anyPossibleMatch) {
1732
+ this.bloomFilterMisses++;
1733
+ this.totalCheckTimeMs += performance.now() - startTime;
1734
+ return [];
1735
+ }
1736
+ this.bloomFilterHits++;
1737
+ }
1738
+ }
1739
+ const affected = [];
1740
+ let anyPreciseMatch = false;
1741
+ for (const sub of this.subscriptions.values()) {
1742
+ if (collections.has(sub.descriptor.collection)) {
1743
+ affected.push(sub);
1744
+ anyPreciseMatch = true;
1745
+ } else if (sub.descriptor.includeCollections) {
1746
+ for (const incCol of sub.descriptor.includeCollections) {
1747
+ if (collections.has(incCol)) {
1748
+ affected.push(sub);
1749
+ anyPreciseMatch = true;
1750
+ break;
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ if (useBloom && !anyPreciseMatch) {
1756
+ this.falsePositives++;
1757
+ }
1758
+ this.totalCheckTimeMs += performance.now() - startTime;
1759
+ return affected;
1760
+ }
1761
+ /**
1762
+ * Rebuild the bloom filter from all current subscriptions.
1763
+ * Adds collection-level dependencies for every subscription, plus
1764
+ * any included collection dependencies.
1765
+ */
1766
+ rebuildBloomFilter() {
1767
+ const filter = new SubscriptionBloomFilter(
1768
+ this.bloomExpectedItems,
1769
+ this.bloomFalsePositiveRate
1770
+ );
1771
+ for (const sub of this.subscriptions.values()) {
1772
+ filter.add(sub.descriptor.collection);
1773
+ if (sub.descriptor.includeCollections) {
1774
+ for (const incCol of sub.descriptor.includeCollections) {
1775
+ filter.add(incCol);
1776
+ }
1777
+ }
1778
+ }
1779
+ this.bloomFilter = filter;
1780
+ this.bloomDirty = false;
1781
+ }
1008
1782
  scheduleFlush() {
1009
1783
  if (this.flushScheduled) return;
1010
1784
  this.flushScheduled = true;
@@ -1023,15 +1797,325 @@ var SubscriptionManager = class {
1023
1797
  }
1024
1798
  };
1025
1799
 
1800
+ // src/transaction/transaction-context.ts
1801
+ var import_core7 = require("@korajs/core");
1802
+ var TransactionContext = class {
1803
+ transactionId;
1804
+ mutationName;
1805
+ buffer = [];
1806
+ committed = false;
1807
+ rolledBack = false;
1808
+ config;
1809
+ constructor(config) {
1810
+ this.config = config;
1811
+ this.transactionId = (0, import_core7.generateUUIDv7)();
1812
+ }
1813
+ /**
1814
+ * Set a human-readable mutation name for this transaction.
1815
+ * Propagated to all operations for DevTools display.
1816
+ */
1817
+ setMutationName(name) {
1818
+ this.mutationName = name;
1819
+ }
1820
+ /**
1821
+ * Get the mutation name, if set.
1822
+ */
1823
+ getMutationName() {
1824
+ return this.mutationName;
1825
+ }
1826
+ /**
1827
+ * Get a collection accessor for buffered operations within this transaction.
1828
+ */
1829
+ collection(name) {
1830
+ const definition = this.config.schema.collections[name];
1831
+ if (!definition) {
1832
+ throw new Error(
1833
+ `Unknown collection "${name}". Available: ${Object.keys(this.config.schema.collections).join(", ")}`
1834
+ );
1835
+ }
1836
+ return {
1837
+ insert: (data) => this.insert(name, definition, data),
1838
+ update: (id, data) => this.update(name, definition, id, data),
1839
+ delete: (id) => this.deleteRecord(name, definition, id),
1840
+ findById: (id) => this.findById(name, definition, id)
1841
+ };
1842
+ }
1843
+ /**
1844
+ * Commit all buffered operations atomically.
1845
+ * Returns the list of operations and affected collections for subscription notification.
1846
+ */
1847
+ async commit() {
1848
+ if (this.committed) {
1849
+ throw new Error("Transaction already committed.");
1850
+ }
1851
+ if (this.rolledBack) {
1852
+ throw new Error("Transaction was rolled back and cannot be committed.");
1853
+ }
1854
+ this.committed = true;
1855
+ if (this.buffer.length === 0) {
1856
+ return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
1857
+ }
1858
+ const operations = [];
1859
+ const affectedCollections = /* @__PURE__ */ new Set();
1860
+ await this.config.adapter.transaction(async (tx) => {
1861
+ for (const entry of this.buffer) {
1862
+ for (const cmd of entry.commands) {
1863
+ await tx.execute(cmd.sql, cmd.params);
1864
+ }
1865
+ operations.push(entry.operation);
1866
+ affectedCollections.add(entry.collection);
1867
+ }
1868
+ });
1869
+ return { operations, affectedCollections };
1870
+ }
1871
+ /**
1872
+ * Mark the transaction as rolled back. No operations will be committed.
1873
+ */
1874
+ rollback() {
1875
+ this.rolledBack = true;
1876
+ this.buffer.length = 0;
1877
+ }
1878
+ /**
1879
+ * Get the transaction ID shared by all operations in this transaction.
1880
+ */
1881
+ getTransactionId() {
1882
+ return this.transactionId;
1883
+ }
1884
+ ensureActive() {
1885
+ if (this.committed) {
1886
+ throw new Error("Cannot perform operations on a committed transaction.");
1887
+ }
1888
+ if (this.rolledBack) {
1889
+ throw new Error("Cannot perform operations on a rolled-back transaction.");
1890
+ }
1891
+ }
1892
+ async insert(collectionName, definition, data) {
1893
+ this.ensureActive();
1894
+ const validated = (0, import_core7.validateRecord)(collectionName, definition, data, "insert");
1895
+ const recordId = (0, import_core7.generateUUIDv7)();
1896
+ const now = Date.now();
1897
+ for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
1898
+ if (descriptor.auto && descriptor.kind === "timestamp") {
1899
+ validated[fieldName] = now;
1900
+ }
1901
+ }
1902
+ const sequenceNumber = this.config.getSequenceNumber();
1903
+ const operation = await (0, import_core7.createOperation)(
1904
+ {
1905
+ nodeId: this.config.nodeId,
1906
+ type: "insert",
1907
+ collection: collectionName,
1908
+ recordId,
1909
+ data: { ...validated },
1910
+ previousData: null,
1911
+ sequenceNumber,
1912
+ causalDeps: [],
1913
+ schemaVersion: this.config.schema.version,
1914
+ transactionId: this.transactionId,
1915
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
1916
+ },
1917
+ this.config.clock
1918
+ );
1919
+ const serializedData = serializeRecord(validated, definition.fields);
1920
+ const record = {
1921
+ id: recordId,
1922
+ ...serializedData,
1923
+ _created_at: now,
1924
+ _updated_at: now
1925
+ };
1926
+ const insertQuery = buildInsertQuery(collectionName, record);
1927
+ const opRow = serializeOperation(operation);
1928
+ const opInsert = buildInsertQuery(
1929
+ `_kora_ops_${collectionName}`,
1930
+ opRow
1931
+ );
1932
+ this.buffer.push({
1933
+ operation,
1934
+ collection: collectionName,
1935
+ commands: [
1936
+ { sql: insertQuery.sql, params: insertQuery.params },
1937
+ { sql: opInsert.sql, params: opInsert.params },
1938
+ {
1939
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1940
+ params: [this.config.nodeId, sequenceNumber]
1941
+ }
1942
+ ]
1943
+ });
1944
+ return {
1945
+ id: recordId,
1946
+ ...validated,
1947
+ createdAt: now,
1948
+ updatedAt: now
1949
+ };
1950
+ }
1951
+ async update(collectionName, definition, id, data) {
1952
+ this.ensureActive();
1953
+ const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
1954
+ if (!currentRecord) {
1955
+ throw new RecordNotFoundError(collectionName, id);
1956
+ }
1957
+ const validated = (0, import_core7.validateRecord)(collectionName, definition, data, "update");
1958
+ const now = Date.now();
1959
+ const previousData = {};
1960
+ const resolvedData = {};
1961
+ const atomicOps = {};
1962
+ for (const key of Object.keys(validated)) {
1963
+ const value = validated[key];
1964
+ previousData[key] = currentRecord[key];
1965
+ if ((0, import_core7.isAtomicOp)(value)) {
1966
+ resolvedData[key] = (0, import_core7.resolveAtomicOp)(currentRecord[key], value);
1967
+ atomicOps[key] = (0, import_core7.toAtomicOp)(value);
1968
+ } else {
1969
+ resolvedData[key] = value;
1970
+ }
1971
+ }
1972
+ const hasAtomicOps = Object.keys(atomicOps).length > 0;
1973
+ const sequenceNumber = this.config.getSequenceNumber();
1974
+ const operation = await (0, import_core7.createOperation)(
1975
+ {
1976
+ nodeId: this.config.nodeId,
1977
+ type: "update",
1978
+ collection: collectionName,
1979
+ recordId: id,
1980
+ data: { ...resolvedData },
1981
+ previousData,
1982
+ sequenceNumber,
1983
+ causalDeps: [],
1984
+ schemaVersion: this.config.schema.version,
1985
+ transactionId: this.transactionId,
1986
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
1987
+ ...hasAtomicOps ? { atomicOps } : {}
1988
+ },
1989
+ this.config.clock
1990
+ );
1991
+ const serializedChanges = serializeRecord(resolvedData, definition.fields);
1992
+ const updateQuery = buildUpdateQuery(collectionName, id, {
1993
+ ...serializedChanges,
1994
+ _updated_at: now
1995
+ });
1996
+ const opRow = serializeOperation(operation);
1997
+ const opInsert = buildInsertQuery(
1998
+ `_kora_ops_${collectionName}`,
1999
+ opRow
2000
+ );
2001
+ this.buffer.push({
2002
+ operation,
2003
+ collection: collectionName,
2004
+ commands: [
2005
+ { sql: updateQuery.sql, params: updateQuery.params },
2006
+ { sql: opInsert.sql, params: opInsert.params },
2007
+ {
2008
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
2009
+ params: [this.config.nodeId, sequenceNumber]
2010
+ }
2011
+ ]
2012
+ });
2013
+ return {
2014
+ ...currentRecord,
2015
+ ...resolvedData,
2016
+ updatedAt: now
2017
+ };
2018
+ }
2019
+ async deleteRecord(collectionName, definition, id) {
2020
+ this.ensureActive();
2021
+ const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
2022
+ if (!currentRecord) {
2023
+ throw new RecordNotFoundError(collectionName, id);
2024
+ }
2025
+ const now = Date.now();
2026
+ const sequenceNumber = this.config.getSequenceNumber();
2027
+ const operation = await (0, import_core7.createOperation)(
2028
+ {
2029
+ nodeId: this.config.nodeId,
2030
+ type: "delete",
2031
+ collection: collectionName,
2032
+ recordId: id,
2033
+ data: null,
2034
+ previousData: null,
2035
+ sequenceNumber,
2036
+ causalDeps: [],
2037
+ schemaVersion: this.config.schema.version,
2038
+ transactionId: this.transactionId,
2039
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
2040
+ },
2041
+ this.config.clock
2042
+ );
2043
+ const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
2044
+ const opRow = serializeOperation(operation);
2045
+ const opInsert = buildInsertQuery(
2046
+ `_kora_ops_${collectionName}`,
2047
+ opRow
2048
+ );
2049
+ this.buffer.push({
2050
+ operation,
2051
+ collection: collectionName,
2052
+ commands: [
2053
+ { sql: deleteQuery.sql, params: deleteQuery.params },
2054
+ { sql: opInsert.sql, params: opInsert.params },
2055
+ {
2056
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
2057
+ params: [this.config.nodeId, sequenceNumber]
2058
+ }
2059
+ ]
2060
+ });
2061
+ }
2062
+ async findById(collectionName, definition, id) {
2063
+ return this.getEffectiveRecord(collectionName, definition, id);
2064
+ }
2065
+ /**
2066
+ * Get the effective state of a record, considering both the database and buffered operations.
2067
+ * Buffered inserts/updates take precedence over the database state.
2068
+ */
2069
+ async getEffectiveRecord(collectionName, definition, id) {
2070
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
2071
+ const entry = this.buffer[i];
2072
+ if (entry.collection === collectionName && entry.operation.recordId === id) {
2073
+ if (entry.operation.type === "delete") {
2074
+ return null;
2075
+ }
2076
+ break;
2077
+ }
2078
+ }
2079
+ const rows = await this.config.adapter.query(
2080
+ `SELECT * FROM ${collectionName} WHERE id = ? AND _deleted = 0`,
2081
+ [id]
2082
+ );
2083
+ let record = rows[0] ? deserializeRecord(rows[0], definition.fields) : null;
2084
+ for (const entry of this.buffer) {
2085
+ if (entry.collection !== collectionName || entry.operation.recordId !== id) {
2086
+ continue;
2087
+ }
2088
+ if (entry.operation.type === "insert" && entry.operation.data) {
2089
+ record = {
2090
+ id,
2091
+ ...entry.operation.data,
2092
+ createdAt: entry.operation.timestamp.wallTime,
2093
+ updatedAt: entry.operation.timestamp.wallTime
2094
+ };
2095
+ } else if (entry.operation.type === "update" && entry.operation.data && record) {
2096
+ record = {
2097
+ ...record,
2098
+ ...entry.operation.data,
2099
+ updatedAt: entry.operation.timestamp.wallTime
2100
+ };
2101
+ } else if (entry.operation.type === "delete") {
2102
+ record = null;
2103
+ }
2104
+ }
2105
+ return record;
2106
+ }
2107
+ };
2108
+
1026
2109
  // src/store/store.ts
1027
2110
  var Store = class {
1028
2111
  opened = false;
1029
2112
  nodeId = "";
1030
2113
  sequenceNumber = 0;
1031
- versionVector = (0, import_core4.createVersionVector)();
2114
+ versionVector = (0, import_core8.createVersionVector)();
1032
2115
  clock = null;
1033
2116
  collections = /* @__PURE__ */ new Map();
1034
2117
  subscriptionManager = new SubscriptionManager();
2118
+ sequenceManager = null;
1035
2119
  schema;
1036
2120
  adapter;
1037
2121
  configNodeId;
@@ -1048,10 +2132,20 @@ var Store = class {
1048
2132
  */
1049
2133
  async open() {
1050
2134
  await this.adapter.open(this.schema);
2135
+ await this.runMigrationsIfNeeded();
1051
2136
  this.nodeId = await this.loadOrGenerateNodeId();
1052
- this.clock = new import_core4.HybridLogicalClock(this.nodeId);
2137
+ this.clock = new import_core8.HybridLogicalClock(this.nodeId);
2138
+ this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
1053
2139
  this.sequenceNumber = await this.loadSequenceNumber();
1054
2140
  this.versionVector = await this.loadVersionVector();
2141
+ const hasRelations = Object.keys(this.schema.relations).length > 0;
2142
+ const relationEnforcer = hasRelations ? new RelationEnforcer({
2143
+ schema: this.schema,
2144
+ adapter: this.adapter,
2145
+ clock: this.clock,
2146
+ nodeId: this.nodeId,
2147
+ getSequenceNumber: () => this.nextSequenceNumber()
2148
+ }) : void 0;
1055
2149
  for (const [name, definition] of Object.entries(this.schema.collections)) {
1056
2150
  const col = new Collection(
1057
2151
  name,
@@ -1066,7 +2160,8 @@ var Store = class {
1066
2160
  if (this.emitter) {
1067
2161
  this.emitter.emit({ type: "operation:created", operation });
1068
2162
  }
1069
- }
2163
+ },
2164
+ relationEnforcer
1070
2165
  );
1071
2166
  this.collections.set(name, col);
1072
2167
  }
@@ -1103,7 +2198,14 @@ var Store = class {
1103
2198
  findById: (id) => col.findById(id),
1104
2199
  update: (id, data) => col.update(id, data),
1105
2200
  delete: (id) => col.delete(id),
1106
- where: (conditions) => new QueryBuilder(name, definition, this.adapter, this.subscriptionManager, conditions, this.schema)
2201
+ where: (conditions) => new QueryBuilder(
2202
+ name,
2203
+ definition,
2204
+ this.adapter,
2205
+ this.subscriptionManager,
2206
+ conditions,
2207
+ this.schema
2208
+ )
1107
2209
  };
1108
2210
  }
1109
2211
  /**
@@ -1218,11 +2320,148 @@ var Store = class {
1218
2320
  getSubscriptionManager() {
1219
2321
  return this.subscriptionManager;
1220
2322
  }
2323
+ /**
2324
+ * Get the sequence manager for offline-safe sequence generation.
2325
+ * @throws {StoreNotOpenError} If the store is not open
2326
+ */
2327
+ getSequenceManager() {
2328
+ this.ensureOpen();
2329
+ if (!this.sequenceManager) {
2330
+ throw new StoreNotOpenError();
2331
+ }
2332
+ return this.sequenceManager;
2333
+ }
2334
+ /**
2335
+ * Create a TransactionContext for atomic multi-collection operations.
2336
+ * The returned context buffers all mutations and commits them atomically.
2337
+ *
2338
+ * After commit, the caller is responsible for notifying subscriptions
2339
+ * and emitting events for each operation.
2340
+ */
2341
+ createTransaction() {
2342
+ this.ensureOpen();
2343
+ if (!this.clock) {
2344
+ throw new StoreNotOpenError();
2345
+ }
2346
+ return new TransactionContext({
2347
+ schema: this.schema,
2348
+ adapter: this.adapter,
2349
+ clock: this.clock,
2350
+ nodeId: this.nodeId,
2351
+ getSequenceNumber: () => this.nextSequenceNumber()
2352
+ });
2353
+ }
2354
+ /**
2355
+ * Execute a function within a transaction. All mutations performed on the
2356
+ * TransactionContext are committed atomically. Subscription notifications
2357
+ * are batched and fired after the commit.
2358
+ *
2359
+ * If the function throws, the transaction is rolled back and the error is re-thrown.
2360
+ *
2361
+ * @param fn - Function receiving a TransactionContext for buffered operations
2362
+ * @returns The operations that were committed
2363
+ */
2364
+ async transaction(fn) {
2365
+ const tx = this.createTransaction();
2366
+ try {
2367
+ await fn(tx);
2368
+ const { operations, affectedCollections } = await tx.commit();
2369
+ for (const op of operations) {
2370
+ this.subscriptionManager.notify(op.collection, op);
2371
+ if (this.emitter) {
2372
+ this.emitter.emit({ type: "operation:created", operation: op });
2373
+ }
2374
+ }
2375
+ return operations;
2376
+ } catch (error) {
2377
+ tx.rollback();
2378
+ throw error;
2379
+ }
2380
+ }
1221
2381
  nextSequenceNumber() {
1222
2382
  this.sequenceNumber++;
1223
2383
  this.versionVector.set(this.nodeId, this.sequenceNumber);
1224
2384
  return this.sequenceNumber;
1225
2385
  }
2386
+ /**
2387
+ * Check the stored schema version and run any pending migrations.
2388
+ * Migrations are applied in version order within a transaction.
2389
+ */
2390
+ async runMigrationsIfNeeded() {
2391
+ const storedVersion = await this.getStoredSchemaVersion();
2392
+ const targetVersion = this.schema.version;
2393
+ if (storedVersion >= targetVersion) {
2394
+ if (storedVersion === 0) {
2395
+ await this.adapter.execute(
2396
+ "INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
2397
+ [String(targetVersion)]
2398
+ );
2399
+ }
2400
+ return;
2401
+ }
2402
+ const migrations = this.schema.migrations ?? {};
2403
+ for (let v = storedVersion + 1; v <= targetVersion; v++) {
2404
+ const migration = migrations[v];
2405
+ if (!migration) continue;
2406
+ const sqlStatements = (0, import_core8.migrationStepsToSQL)(migration.steps);
2407
+ for (const sql of sqlStatements) {
2408
+ try {
2409
+ await this.adapter.execute(sql);
2410
+ } catch (e) {
2411
+ const msg = e.message || "";
2412
+ if (!msg.includes("duplicate column name")) {
2413
+ throw e;
2414
+ }
2415
+ }
2416
+ }
2417
+ const backfillSteps = migration.steps.filter(
2418
+ (s) => s.type === "backfill"
2419
+ );
2420
+ for (const step of backfillSteps) {
2421
+ await this.runBackfill(step.collection, step.transform);
2422
+ }
2423
+ }
2424
+ await this.adapter.execute(
2425
+ "INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
2426
+ [String(targetVersion)]
2427
+ );
2428
+ }
2429
+ /**
2430
+ * Get the stored schema version from _kora_meta. Returns 0 if not set.
2431
+ */
2432
+ async getStoredSchemaVersion() {
2433
+ const rows = await this.adapter.query(
2434
+ "SELECT value FROM _kora_meta WHERE key = 'schema_version'"
2435
+ );
2436
+ return rows[0] ? Number(rows[0].value) : 0;
2437
+ }
2438
+ /**
2439
+ * Run a backfill transform on all records in a collection.
2440
+ * Reads all rows, applies the transform, and updates changed fields.
2441
+ */
2442
+ async runBackfill(collection, transform) {
2443
+ const rows = await this.adapter.query(
2444
+ `SELECT * FROM ${collection} WHERE _deleted = 0`
2445
+ );
2446
+ await this.adapter.transaction(async (tx) => {
2447
+ for (const row of rows) {
2448
+ const updates = transform(row);
2449
+ const fields = Object.keys(updates);
2450
+ if (fields.length === 0) continue;
2451
+ const setClauses = fields.map((f) => `${f} = ?`).join(", ");
2452
+ const values = fields.map((f) => {
2453
+ const val = updates[f];
2454
+ if (typeof val === "boolean") return val ? 1 : 0;
2455
+ if (Array.isArray(val) || typeof val === "object" && val !== null) {
2456
+ return JSON.stringify(val);
2457
+ }
2458
+ return val;
2459
+ });
2460
+ values.push(row.id);
2461
+ await tx.execute(`UPDATE ${collection} SET ${setClauses} WHERE id = ?`, values);
2462
+ }
2463
+ });
2464
+ }
1226
2465
  async loadOrGenerateNodeId() {
1227
2466
  if (this.configNodeId) {
1228
2467
  await this.adapter.execute(
@@ -1237,7 +2476,7 @@ var Store = class {
1237
2476
  if (rows[0]) {
1238
2477
  return rows[0].value;
1239
2478
  }
1240
- const newNodeId = (0, import_core4.generateUUIDv7)();
2479
+ const newNodeId = (0, import_core8.generateUUIDv7)();
1241
2480
  await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
1242
2481
  newNodeId
1243
2482
  ]);
@@ -1254,7 +2493,7 @@ var Store = class {
1254
2493
  const rows = await this.adapter.query(
1255
2494
  "SELECT node_id, sequence_number FROM _kora_version_vector"
1256
2495
  );
1257
- const vector = (0, import_core4.createVersionVector)();
2496
+ const vector = (0, import_core8.createVersionVector)();
1258
2497
  for (const row of rows) {
1259
2498
  vector.set(row.node_id, row.sequence_number);
1260
2499
  }
@@ -1270,19 +2509,25 @@ var Store = class {
1270
2509
  0 && (module.exports = {
1271
2510
  AdapterError,
1272
2511
  Collection,
2512
+ InvalidStateTransitionError,
1273
2513
  PersistenceError,
1274
2514
  QueryBuilder,
1275
2515
  QueryError,
1276
2516
  RecordNotFoundError,
2517
+ SequenceManager,
1277
2518
  Store,
1278
2519
  StoreNotOpenError,
2520
+ SubscriptionBloomFilter,
1279
2521
  SubscriptionManager,
2522
+ TransactionContext,
1280
2523
  WorkerInitError,
1281
2524
  WorkerTimeoutError,
1282
2525
  decodeRichtext,
1283
2526
  encodeRichtext,
1284
2527
  pluralize,
1285
2528
  richtextToPlainText,
1286
- singularize
2529
+ singularize,
2530
+ validateStateTransition,
2531
+ validateUpdateStateMachine
1287
2532
  });
1288
2533
  //# sourceMappingURL=index.cjs.map