@korajs/store 0.3.2 → 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.js CHANGED
@@ -9,10 +9,22 @@ import {
9
9
  } from "./chunk-LAWV6CFH.js";
10
10
 
11
11
  // src/store/store.ts
12
- import { HybridLogicalClock as HybridLogicalClock2, createVersionVector, generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
12
+ import {
13
+ HybridLogicalClock as HybridLogicalClock2,
14
+ createVersionVector,
15
+ generateUUIDv7 as generateUUIDv73,
16
+ migrationStepsToSQL
17
+ } from "@korajs/core";
13
18
 
14
19
  // src/collection/collection.ts
15
- import { createOperation, generateUUIDv7, validateRecord } from "@korajs/core";
20
+ import {
21
+ createOperation,
22
+ generateUUIDv7,
23
+ isAtomicOp,
24
+ resolveAtomicOp,
25
+ toAtomicOp,
26
+ validateRecord
27
+ } from "@korajs/core";
16
28
 
17
29
  // src/query/sql-builder.ts
18
30
  function buildSelectQuery(descriptor, fields) {
@@ -195,7 +207,9 @@ function decodeRichtext(value) {
195
207
  if (value instanceof ArrayBuffer) {
196
208
  return new Uint8Array(value);
197
209
  }
198
- throw new Error("Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined.");
210
+ throw new Error(
211
+ "Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined."
212
+ );
199
213
  }
200
214
  function richtextToPlainText(value) {
201
215
  const encoded = encodeRichtext(value);
@@ -234,13 +248,38 @@ function deserializeRecord(row, fields) {
234
248
  }
235
249
  return result;
236
250
  }
251
+ var ATOMIC_OPS_KEY = "__kora_atomic_ops__";
252
+ var TX_ID_KEY = "__kora_tx_id__";
253
+ var MUTATION_NAME_KEY = "__kora_mutation__";
237
254
  function serializeOperation(op) {
255
+ const hasMetadata = op.transactionId !== void 0 || op.mutationName !== void 0;
256
+ let dataPayload = null;
257
+ if (op.data) {
258
+ dataPayload = { ...op.data };
259
+ if (op.atomicOps !== void 0 && Object.keys(op.atomicOps).length > 0) {
260
+ dataPayload[ATOMIC_OPS_KEY] = op.atomicOps;
261
+ }
262
+ if (op.transactionId !== void 0) {
263
+ dataPayload[TX_ID_KEY] = op.transactionId;
264
+ }
265
+ if (op.mutationName !== void 0) {
266
+ dataPayload[MUTATION_NAME_KEY] = op.mutationName;
267
+ }
268
+ } else if (hasMetadata) {
269
+ dataPayload = {};
270
+ if (op.transactionId !== void 0) {
271
+ dataPayload[TX_ID_KEY] = op.transactionId;
272
+ }
273
+ if (op.mutationName !== void 0) {
274
+ dataPayload[MUTATION_NAME_KEY] = op.mutationName;
275
+ }
276
+ }
238
277
  return {
239
278
  id: op.id,
240
279
  node_id: op.nodeId,
241
280
  type: op.type,
242
281
  record_id: op.recordId,
243
- data: op.data ? JSON.stringify(op.data) : null,
282
+ data: dataPayload ? JSON.stringify(dataPayload) : null,
244
283
  previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
245
284
  timestamp: HybridLogicalClock.serialize(op.timestamp),
246
285
  sequence_number: op.sequenceNumber,
@@ -249,6 +288,24 @@ function serializeOperation(op) {
249
288
  };
250
289
  }
251
290
  function deserializeOperation(row) {
291
+ let data = null;
292
+ let atomicOps;
293
+ let transactionId;
294
+ let mutationName;
295
+ if (row.data) {
296
+ const parsed = JSON.parse(row.data);
297
+ if (ATOMIC_OPS_KEY in parsed) {
298
+ atomicOps = parsed[ATOMIC_OPS_KEY];
299
+ }
300
+ if (TX_ID_KEY in parsed) {
301
+ transactionId = parsed[TX_ID_KEY];
302
+ }
303
+ if (MUTATION_NAME_KEY in parsed) {
304
+ mutationName = parsed[MUTATION_NAME_KEY];
305
+ }
306
+ const { [ATOMIC_OPS_KEY]: _a, [TX_ID_KEY]: _t, [MUTATION_NAME_KEY]: _m, ...rest } = parsed;
307
+ data = Object.keys(rest).length > 0 ? rest : null;
308
+ }
252
309
  return {
253
310
  id: row.id,
254
311
  nodeId: row.node_id,
@@ -256,12 +313,15 @@ function deserializeOperation(row) {
256
313
  collection: "",
257
314
  // Collection name is derived from the table name by the caller
258
315
  recordId: row.record_id,
259
- data: row.data ? JSON.parse(row.data) : null,
316
+ data,
260
317
  previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
261
318
  timestamp: HybridLogicalClock.deserialize(row.timestamp),
262
319
  sequenceNumber: row.sequence_number,
263
320
  causalDeps: JSON.parse(row.causal_deps),
264
- schemaVersion: row.schema_version
321
+ schemaVersion: row.schema_version,
322
+ ...atomicOps !== void 0 ? { atomicOps } : {},
323
+ ...transactionId !== void 0 ? { transactionId } : {},
324
+ ...mutationName !== void 0 ? { mutationName } : {}
265
325
  };
266
326
  }
267
327
  function deserializeOperationWithCollection(row, collection) {
@@ -299,9 +359,91 @@ function deserializeValue(value, descriptor) {
299
359
  }
300
360
  }
301
361
 
362
+ // src/state-machine/state-validator.ts
363
+ import { KoraError, validateTransition } from "@korajs/core";
364
+ var InvalidStateTransitionError = class extends KoraError {
365
+ constructor(collection, recordId, field, fromState, toState, allowedStates) {
366
+ super(
367
+ `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)"}`,
368
+ "INVALID_STATE_TRANSITION",
369
+ { collection, recordId, field, fromState, toState, allowedStates }
370
+ );
371
+ this.collection = collection;
372
+ this.recordId = recordId;
373
+ this.field = field;
374
+ this.fromState = fromState;
375
+ this.toState = toState;
376
+ this.allowedStates = allowedStates;
377
+ this.name = "InvalidStateTransitionError";
378
+ }
379
+ collection;
380
+ recordId;
381
+ field;
382
+ fromState;
383
+ toState;
384
+ allowedStates;
385
+ };
386
+ function validateStateTransition(collectionName, recordId, stateMachine, currentState, newState) {
387
+ if (currentState === null) {
388
+ return { valid: true, allowedStates: [] };
389
+ }
390
+ if (currentState === newState) {
391
+ return { valid: true, allowedStates: stateMachine.transitions[currentState] ?? [] };
392
+ }
393
+ const constraint = {
394
+ field: stateMachine.field,
395
+ collection: collectionName,
396
+ transitions: stateMachine.transitions
397
+ };
398
+ const transitionResult = validateTransition(constraint, currentState, newState);
399
+ if (transitionResult.valid) {
400
+ return { valid: true, allowedStates: transitionResult.allowedTargets };
401
+ }
402
+ const allowedStates = transitionResult.allowedTargets;
403
+ if (stateMachine.onInvalidTransition === "reject") {
404
+ throw new InvalidStateTransitionError(
405
+ collectionName,
406
+ recordId,
407
+ stateMachine.field,
408
+ currentState,
409
+ newState,
410
+ allowedStates
411
+ );
412
+ }
413
+ return { valid: false, allowedStates };
414
+ }
415
+ function validateUpdateStateMachine(collectionName, recordId, collectionDef, currentRecord, updateData) {
416
+ const stateMachine = collectionDef.stateMachine;
417
+ if (stateMachine === void 0) {
418
+ return updateData;
419
+ }
420
+ const stateField = stateMachine.field;
421
+ if (!(stateField in updateData)) {
422
+ return updateData;
423
+ }
424
+ const currentState = currentRecord[stateField];
425
+ const newState = updateData[stateField];
426
+ if (typeof currentState !== "string" || typeof newState !== "string") {
427
+ return updateData;
428
+ }
429
+ const result = validateStateTransition(
430
+ collectionName,
431
+ recordId,
432
+ stateMachine,
433
+ currentState,
434
+ newState
435
+ );
436
+ if (result.valid) {
437
+ return updateData;
438
+ }
439
+ const filtered = { ...updateData };
440
+ delete filtered[stateField];
441
+ return filtered;
442
+ }
443
+
302
444
  // src/collection/collection.ts
303
445
  var Collection = class {
304
- constructor(name, definition, schema, adapter, clock, nodeId, getSequenceNumber, onMutation) {
446
+ constructor(name, definition, schema, adapter, clock, nodeId, getSequenceNumber, onMutation, relationEnforcer) {
305
447
  this.name = name;
306
448
  this.definition = definition;
307
449
  this.schema = schema;
@@ -310,6 +452,7 @@ var Collection = class {
310
452
  this.nodeId = nodeId;
311
453
  this.getSequenceNumber = getSequenceNumber;
312
454
  this.onMutation = onMutation;
455
+ this.relationEnforcer = relationEnforcer ?? null;
313
456
  }
314
457
  name;
315
458
  definition;
@@ -319,6 +462,7 @@ var Collection = class {
319
462
  nodeId;
320
463
  getSequenceNumber;
321
464
  onMutation;
465
+ relationEnforcer;
322
466
  /**
323
467
  * Insert a new record into the collection.
324
468
  * Generates a UUID v7 for the id, validates data, and persists atomically.
@@ -408,13 +552,37 @@ var Collection = class {
408
552
  if (!currentRow) {
409
553
  throw new RecordNotFoundError(this.name, id);
410
554
  }
411
- const validated = validateRecord(this.name, this.definition, data, "update");
555
+ let validated = validateRecord(this.name, this.definition, data, "update");
412
556
  const now = Date.now();
413
- const previousData = {};
414
557
  const currentRecord = deserializeRecord(currentRow, this.definition.fields);
558
+ validated = validateUpdateStateMachine(
559
+ this.name,
560
+ id,
561
+ this.definition,
562
+ currentRecord,
563
+ validated
564
+ );
565
+ if (Object.keys(validated).length === 0) {
566
+ const fullRecord = await this.findById(id);
567
+ if (!fullRecord) {
568
+ throw new RecordNotFoundError(this.name, id);
569
+ }
570
+ return fullRecord;
571
+ }
572
+ const previousData = {};
573
+ const resolvedData = {};
574
+ const atomicOps = {};
415
575
  for (const key of Object.keys(validated)) {
576
+ const value = validated[key];
416
577
  previousData[key] = currentRecord[key];
578
+ if (isAtomicOp(value)) {
579
+ resolvedData[key] = resolveAtomicOp(currentRecord[key], value);
580
+ atomicOps[key] = toAtomicOp(value);
581
+ } else {
582
+ resolvedData[key] = value;
583
+ }
417
584
  }
585
+ const hasAtomicOps = Object.keys(atomicOps).length > 0;
418
586
  const sequenceNumber = this.getSequenceNumber();
419
587
  const operation = await createOperation(
420
588
  {
@@ -422,15 +590,16 @@ var Collection = class {
422
590
  type: "update",
423
591
  collection: this.name,
424
592
  recordId: id,
425
- data: { ...validated },
593
+ data: { ...resolvedData },
426
594
  previousData,
427
595
  sequenceNumber,
428
596
  causalDeps: [],
429
- schemaVersion: this.schema.version
597
+ schemaVersion: this.schema.version,
598
+ ...hasAtomicOps ? { atomicOps } : {}
430
599
  },
431
600
  this.clock
432
601
  );
433
- const serializedChanges = serializeRecord(validated, this.definition.fields);
602
+ const serializedChanges = serializeRecord(resolvedData, this.definition.fields);
434
603
  const updateQuery = buildUpdateQuery(this.name, id, {
435
604
  ...serializedChanges,
436
605
  _updated_at: now
@@ -458,8 +627,19 @@ var Collection = class {
458
627
  /**
459
628
  * Soft-delete a record by its ID.
460
629
  *
630
+ * When a RelationEnforcer is configured, referential integrity policies
631
+ * are enforced within the same transaction:
632
+ * - **cascade**: Recursively deletes all referencing records
633
+ * - **set-null**: Sets foreign keys to null on referencing records
634
+ * - **restrict**: Throws ReferentialIntegrityError if references exist
635
+ * - **no-action**: Does nothing (dangling references are left)
636
+ *
637
+ * All cascaded operations are created atomically within the same transaction
638
+ * and share causal dependencies with the original delete.
639
+ *
461
640
  * @param id - The record ID to delete
462
641
  * @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
642
+ * @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
463
643
  */
464
644
  async delete(id) {
465
645
  const currentRows = await this.adapter.query(
@@ -491,7 +671,17 @@ var Collection = class {
491
671
  `_kora_ops_${this.name}`,
492
672
  opRow
493
673
  );
674
+ const cascadedOps = [];
494
675
  await this.adapter.transaction(async (tx) => {
676
+ if (this.relationEnforcer) {
677
+ const enforcementResult = await this.relationEnforcer.enforceDelete(
678
+ this.name,
679
+ id,
680
+ tx,
681
+ [operation.id]
682
+ );
683
+ cascadedOps.push(...enforcementResult.operations);
684
+ }
495
685
  await tx.execute(deleteQuery.sql, deleteQuery.params);
496
686
  await tx.execute(opInsert.sql, opInsert.params);
497
687
  await tx.execute(
@@ -500,6 +690,9 @@ var Collection = class {
500
690
  );
501
691
  });
502
692
  this.onMutation(this.name, operation);
693
+ for (const cascadedOp of cascadedOps) {
694
+ this.onMutation(cascadedOp.collection, cascadedOp);
695
+ }
503
696
  }
504
697
  /** Get the collection name */
505
698
  getName() {
@@ -804,12 +997,500 @@ var QueryError2 = class extends Error {
804
997
  }
805
998
  };
806
999
 
1000
+ // src/relations/relation-enforcer.ts
1001
+ import { KoraError as KoraError2, createOperation as createOperation2 } from "@korajs/core";
1002
+
1003
+ // src/relations/relation-lookup.ts
1004
+ function buildRelationLookup(schema) {
1005
+ const lookup = /* @__PURE__ */ new Map();
1006
+ for (const [relationName, relation] of Object.entries(schema.relations)) {
1007
+ const targetCollection = relation.to;
1008
+ const existing = lookup.get(targetCollection) ?? [];
1009
+ existing.push({
1010
+ relationName,
1011
+ sourceCollection: relation.from,
1012
+ foreignKeyField: relation.field,
1013
+ onDelete: relation.onDelete,
1014
+ relation
1015
+ });
1016
+ lookup.set(targetCollection, existing);
1017
+ }
1018
+ return lookup;
1019
+ }
1020
+ function getIncomingRelations(lookup, collection) {
1021
+ return lookup.get(collection) ?? [];
1022
+ }
1023
+
1024
+ // src/relations/relation-enforcer.ts
1025
+ var ReferentialIntegrityError = class extends KoraError2 {
1026
+ constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
1027
+ super(
1028
+ `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.`,
1029
+ "REFERENTIAL_INTEGRITY",
1030
+ {
1031
+ collection,
1032
+ recordId,
1033
+ referencingCollection,
1034
+ relationName,
1035
+ referencingCount
1036
+ }
1037
+ );
1038
+ this.name = "ReferentialIntegrityError";
1039
+ }
1040
+ };
1041
+ var RelationEnforcer = class {
1042
+ lookup;
1043
+ schema;
1044
+ adapter;
1045
+ clock;
1046
+ nodeId;
1047
+ getSequenceNumber;
1048
+ constructor(config) {
1049
+ this.schema = config.schema;
1050
+ this.adapter = config.adapter;
1051
+ this.clock = config.clock;
1052
+ this.nodeId = config.nodeId;
1053
+ this.getSequenceNumber = config.getSequenceNumber;
1054
+ this.lookup = buildRelationLookup(config.schema);
1055
+ }
1056
+ /**
1057
+ * Enforce referential integrity after deleting a record.
1058
+ *
1059
+ * Must be called within a transaction. The transaction handle is used
1060
+ * for all cascaded writes to ensure atomicity.
1061
+ *
1062
+ * @param collection - The collection the deleted record belongs to
1063
+ * @param recordId - The ID of the deleted record
1064
+ * @param tx - The active transaction handle
1065
+ * @param causalDeps - Causal dependencies for generated operations
1066
+ * @returns All additional operations created as side effects
1067
+ * @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
1068
+ */
1069
+ async enforceDelete(collection, recordId, tx, causalDeps) {
1070
+ const incomingRelations = getIncomingRelations(this.lookup, collection);
1071
+ if (incomingRelations.length === 0) {
1072
+ return { operations: [] };
1073
+ }
1074
+ const allOperations = [];
1075
+ const sortedRelations = [...incomingRelations].sort(
1076
+ (a, b) => a.relationName.localeCompare(b.relationName)
1077
+ );
1078
+ for (const incoming of sortedRelations) {
1079
+ const ops = await this.enforceRelation(incoming, recordId, tx, causalDeps);
1080
+ allOperations.push(...ops);
1081
+ }
1082
+ return { operations: allOperations };
1083
+ }
1084
+ /**
1085
+ * Enforce a single relation's onDelete policy.
1086
+ */
1087
+ async enforceRelation(incoming, deletedRecordId, tx, causalDeps) {
1088
+ switch (incoming.onDelete) {
1089
+ case "cascade":
1090
+ return this.enforceCascade(incoming, deletedRecordId, tx, causalDeps);
1091
+ case "set-null":
1092
+ return this.enforceSetNull(incoming, deletedRecordId, tx, causalDeps);
1093
+ case "restrict":
1094
+ return this.enforceRestrict(incoming, deletedRecordId, tx);
1095
+ case "no-action":
1096
+ return [];
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Cascade: delete all records in the source collection that reference
1101
+ * the deleted record, then recursively cascade those deletes.
1102
+ */
1103
+ async enforceCascade(incoming, deletedRecordId, tx, causalDeps) {
1104
+ const { sourceCollection, foreignKeyField } = incoming;
1105
+ const referencingRows = await tx.query(
1106
+ `SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1107
+ [deletedRecordId]
1108
+ );
1109
+ if (referencingRows.length === 0) {
1110
+ return [];
1111
+ }
1112
+ const operations = [];
1113
+ for (const row of referencingRows) {
1114
+ const now = Date.now();
1115
+ const sequenceNumber = this.getSequenceNumber();
1116
+ const operation = await createOperation2(
1117
+ {
1118
+ nodeId: this.nodeId,
1119
+ type: "delete",
1120
+ collection: sourceCollection,
1121
+ recordId: row.id,
1122
+ data: null,
1123
+ previousData: null,
1124
+ sequenceNumber,
1125
+ causalDeps: [...causalDeps],
1126
+ schemaVersion: this.schema.version
1127
+ },
1128
+ this.clock
1129
+ );
1130
+ const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
1131
+ await tx.execute(deleteQuery.sql, deleteQuery.params);
1132
+ const opRow = serializeOperation(operation);
1133
+ const opInsert = buildInsertQuery(
1134
+ `_kora_ops_${sourceCollection}`,
1135
+ opRow
1136
+ );
1137
+ await tx.execute(opInsert.sql, opInsert.params);
1138
+ await tx.execute(
1139
+ "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1140
+ [this.nodeId, sequenceNumber]
1141
+ );
1142
+ operations.push(operation);
1143
+ const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
1144
+ operations.push(...cascadeResult.operations);
1145
+ }
1146
+ return operations;
1147
+ }
1148
+ /**
1149
+ * Set-null: update all referencing records to set the foreign key to null.
1150
+ */
1151
+ async enforceSetNull(incoming, deletedRecordId, tx, causalDeps) {
1152
+ const { sourceCollection, foreignKeyField } = incoming;
1153
+ const collectionDef = this.schema.collections[sourceCollection];
1154
+ if (!collectionDef) {
1155
+ return [];
1156
+ }
1157
+ const referencingRows = await tx.query(
1158
+ `SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1159
+ [deletedRecordId]
1160
+ );
1161
+ if (referencingRows.length === 0) {
1162
+ return [];
1163
+ }
1164
+ const operations = [];
1165
+ for (const row of referencingRows) {
1166
+ const now = Date.now();
1167
+ const sequenceNumber = this.getSequenceNumber();
1168
+ const updateData = { [foreignKeyField]: null };
1169
+ const previousData = { [foreignKeyField]: deletedRecordId };
1170
+ const operation = await createOperation2(
1171
+ {
1172
+ nodeId: this.nodeId,
1173
+ type: "update",
1174
+ collection: sourceCollection,
1175
+ recordId: row.id,
1176
+ data: { ...updateData },
1177
+ previousData,
1178
+ sequenceNumber,
1179
+ causalDeps: [...causalDeps],
1180
+ schemaVersion: this.schema.version
1181
+ },
1182
+ this.clock
1183
+ );
1184
+ const serializedChanges = serializeRecord(updateData, collectionDef.fields);
1185
+ const updateQuery = buildUpdateQuery(sourceCollection, row.id, {
1186
+ ...serializedChanges,
1187
+ _updated_at: now
1188
+ });
1189
+ await tx.execute(updateQuery.sql, updateQuery.params);
1190
+ const opRow = serializeOperation(operation);
1191
+ const opInsert = buildInsertQuery(
1192
+ `_kora_ops_${sourceCollection}`,
1193
+ opRow
1194
+ );
1195
+ await tx.execute(opInsert.sql, opInsert.params);
1196
+ await tx.execute(
1197
+ "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1198
+ [this.nodeId, sequenceNumber]
1199
+ );
1200
+ operations.push(operation);
1201
+ }
1202
+ return operations;
1203
+ }
1204
+ /**
1205
+ * Restrict: refuse the delete if any referencing records exist.
1206
+ */
1207
+ async enforceRestrict(incoming, deletedRecordId, tx) {
1208
+ const { sourceCollection, foreignKeyField, relationName } = incoming;
1209
+ const countRows = await tx.query(
1210
+ `SELECT COUNT(*) as cnt FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
1211
+ [deletedRecordId]
1212
+ );
1213
+ const count = countRows[0]?.cnt ?? 0;
1214
+ if (count > 0) {
1215
+ const targetCollection = incoming.relation.to;
1216
+ throw new ReferentialIntegrityError(
1217
+ targetCollection,
1218
+ deletedRecordId,
1219
+ sourceCollection,
1220
+ relationName,
1221
+ count
1222
+ );
1223
+ }
1224
+ return [];
1225
+ }
1226
+ /**
1227
+ * Get the relation lookup map for external use (e.g., by the merge engine).
1228
+ */
1229
+ getRelationLookup() {
1230
+ return this.lookup;
1231
+ }
1232
+ };
1233
+
1234
+ // src/sequences/sequence-manager.ts
1235
+ import { defaultSequenceFormat, formatSequenceValue } from "@korajs/core";
1236
+ var SequenceManager = class {
1237
+ adapter;
1238
+ nodeId;
1239
+ constructor(adapter, nodeId) {
1240
+ this.adapter = adapter;
1241
+ this.nodeId = nodeId;
1242
+ }
1243
+ /**
1244
+ * Get the next value in a sequence, atomically incrementing the counter.
1245
+ *
1246
+ * @param name - The sequence name (e.g., 'receipt', 'invoice')
1247
+ * @param config - Optional configuration for scope, format, and starting value
1248
+ * @returns The formatted sequence value
1249
+ */
1250
+ async next(name, config) {
1251
+ const scope = config?.scope ?? "";
1252
+ const startAt = config?.startAt ?? 1;
1253
+ const format = config?.format ?? defaultSequenceFormat(name);
1254
+ let counter = 0;
1255
+ await this.adapter.transaction(async (tx) => {
1256
+ const rows = await tx.query(
1257
+ "SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1258
+ [name, scope, this.nodeId]
1259
+ );
1260
+ if (rows.length > 0) {
1261
+ const row = rows[0];
1262
+ counter = row.counter + 1;
1263
+ await tx.execute(
1264
+ "UPDATE _kora_sequences SET counter = ? WHERE name = ? AND scope = ? AND node_id = ?",
1265
+ [counter, name, scope, this.nodeId]
1266
+ );
1267
+ } else {
1268
+ counter = startAt;
1269
+ await tx.execute(
1270
+ "INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
1271
+ [name, scope, this.nodeId, counter]
1272
+ );
1273
+ }
1274
+ });
1275
+ return formatSequenceValue(format, counter, this.nodeId);
1276
+ }
1277
+ /**
1278
+ * Get the current counter value without incrementing.
1279
+ *
1280
+ * @param name - The sequence name
1281
+ * @param config - Optional scope
1282
+ * @returns The current counter value, or 0 if the sequence has never been used
1283
+ */
1284
+ async current(name, config) {
1285
+ const scope = config?.scope ?? "";
1286
+ const rows = await this.adapter.query(
1287
+ "SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1288
+ [name, scope, this.nodeId]
1289
+ );
1290
+ if (rows.length > 0) {
1291
+ return rows[0].counter;
1292
+ }
1293
+ return 0;
1294
+ }
1295
+ /**
1296
+ * Reset a sequence counter.
1297
+ *
1298
+ * @param name - The sequence name
1299
+ * @param config - Optional scope and target value
1300
+ */
1301
+ async reset(name, config) {
1302
+ const scope = config?.scope ?? "";
1303
+ const to = config?.to ?? 0;
1304
+ await this.adapter.execute(
1305
+ "DELETE FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
1306
+ [name, scope, this.nodeId]
1307
+ );
1308
+ if (to > 0) {
1309
+ await this.adapter.execute(
1310
+ "INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
1311
+ [name, scope, this.nodeId, to]
1312
+ );
1313
+ }
1314
+ }
1315
+ };
1316
+
1317
+ // src/subscription/bloom-filter.ts
1318
+ var FNV_OFFSET_BASIS = 2166136261;
1319
+ var FNV_PRIME = 16777619;
1320
+ function fnv1a32(input) {
1321
+ let hash = FNV_OFFSET_BASIS;
1322
+ for (let i = 0; i < input.length; i++) {
1323
+ hash ^= input.charCodeAt(i);
1324
+ hash = Math.imul(hash, FNV_PRIME);
1325
+ }
1326
+ return hash >>> 0;
1327
+ }
1328
+ function optimalBitCount(expectedItems, falsePositiveRate) {
1329
+ if (expectedItems <= 0) return 32;
1330
+ if (falsePositiveRate <= 0 || falsePositiveRate >= 1) return 32;
1331
+ const ln2Squared = Math.LN2 * Math.LN2;
1332
+ const rawBits = -(expectedItems * Math.log(falsePositiveRate)) / ln2Squared;
1333
+ const aligned = Math.ceil(rawBits / 32) * 32;
1334
+ return Math.max(32, aligned);
1335
+ }
1336
+ function optimalHashCount(bitCount, expectedItems) {
1337
+ if (expectedItems <= 0) return 1;
1338
+ const raw = bitCount / expectedItems * Math.LN2;
1339
+ return Math.max(1, Math.min(30, Math.round(raw)));
1340
+ }
1341
+ var SubscriptionBloomFilter = class {
1342
+ bits;
1343
+ bitCount;
1344
+ hashCount;
1345
+ itemCount = 0;
1346
+ constructor(expectedItems, falsePositiveRate = 0.01) {
1347
+ this.bitCount = optimalBitCount(expectedItems, falsePositiveRate);
1348
+ this.hashCount = optimalHashCount(this.bitCount, expectedItems);
1349
+ this.bits = new Uint32Array(this.bitCount / 32);
1350
+ }
1351
+ /**
1352
+ * Add a collection (and optional field) to the bloom filter.
1353
+ *
1354
+ * @param collection - Collection name (e.g., "todos")
1355
+ * @param field - Optional field name (e.g., "completed")
1356
+ */
1357
+ add(collection, field) {
1358
+ const key = field !== void 0 ? `${collection}:${field}` : collection;
1359
+ const positions = this.getPositions(key);
1360
+ for (const pos of positions) {
1361
+ const wordIndex = pos >>> 5;
1362
+ const bitIndex = pos & 31;
1363
+ const current = this.bits[wordIndex] ?? 0;
1364
+ this.bits[wordIndex] = current | 1 << bitIndex;
1365
+ }
1366
+ this.itemCount++;
1367
+ }
1368
+ /**
1369
+ * Check if a collection (and optional field) might be in the filter.
1370
+ *
1371
+ * A return value of `false` means the item is DEFINITELY NOT in the filter.
1372
+ * A return value of `true` means the item MIGHT be in the filter (possible false positive).
1373
+ *
1374
+ * @param collection - Collection name to check
1375
+ * @param field - Optional field name to check
1376
+ * @returns false = definitely absent, true = possibly present
1377
+ */
1378
+ mightContain(collection, field) {
1379
+ const key = field !== void 0 ? `${collection}:${field}` : collection;
1380
+ const positions = this.getPositions(key);
1381
+ for (const pos of positions) {
1382
+ const wordIndex = pos >>> 5;
1383
+ const bitIndex = pos & 31;
1384
+ if (((this.bits[wordIndex] ?? 0) & 1 << bitIndex) === 0) {
1385
+ return false;
1386
+ }
1387
+ }
1388
+ return true;
1389
+ }
1390
+ /**
1391
+ * Reset the bloom filter, clearing all bits and the item count.
1392
+ */
1393
+ clear() {
1394
+ this.bits.fill(0);
1395
+ this.itemCount = 0;
1396
+ }
1397
+ /**
1398
+ * Estimate the current false positive rate based on the number of items inserted.
1399
+ *
1400
+ * Formula: (1 - e^(-kn/m))^k
1401
+ * where k = hash count, n = item count, m = bit count.
1402
+ *
1403
+ * @returns Estimated false positive rate as a number between 0 and 1
1404
+ */
1405
+ estimatedFalsePositiveRate() {
1406
+ if (this.itemCount === 0) return 0;
1407
+ const exponent = -(this.hashCount * this.itemCount) / this.bitCount;
1408
+ return (1 - Math.exp(exponent)) ** this.hashCount;
1409
+ }
1410
+ /**
1411
+ * @returns The number of items that have been added to the filter
1412
+ */
1413
+ getItemCount() {
1414
+ return this.itemCount;
1415
+ }
1416
+ /**
1417
+ * @returns The total number of bits in the filter
1418
+ */
1419
+ getBitCount() {
1420
+ return this.bitCount;
1421
+ }
1422
+ /**
1423
+ * @returns The number of hash functions used
1424
+ */
1425
+ getHashCount() {
1426
+ return this.hashCount;
1427
+ }
1428
+ /**
1429
+ * Count the number of bits currently set to 1.
1430
+ * Uses Brian Kernighan's algorithm: each iteration clears the lowest set bit,
1431
+ * so the loop runs exactly as many times as there are set bits.
1432
+ *
1433
+ * @returns Number of bits set to 1
1434
+ */
1435
+ getSetBitCount() {
1436
+ let count = 0;
1437
+ for (let i = 0; i < this.bits.length; i++) {
1438
+ let word = this.bits[i] ?? 0;
1439
+ while (word !== 0) {
1440
+ word &= word - 1;
1441
+ count++;
1442
+ }
1443
+ }
1444
+ return count;
1445
+ }
1446
+ /**
1447
+ * Compute k bit positions for a given key using Kirsch-Mitzenmacker double hashing.
1448
+ *
1449
+ * Instead of computing k independent hash functions, we compute two base hashes
1450
+ * (h1 and h2) and derive the rest as: h_i = (h1 + i * h2) mod m.
1451
+ * This technique is proven to have the same asymptotic false positive rate as
1452
+ * k independent hash functions.
1453
+ *
1454
+ * We derive h1 and h2 from a single FNV-1a hash by splitting and mixing:
1455
+ * h1 = fnv1a(key), h2 = fnv1a(key + "\0salt") to ensure independence.
1456
+ */
1457
+ getPositions(key) {
1458
+ const h1 = fnv1a32(key);
1459
+ const h2 = fnv1a32(`${key}\0bloom`);
1460
+ const positions = new Array(this.hashCount);
1461
+ for (let i = 0; i < this.hashCount; i++) {
1462
+ positions[i] = (h1 + Math.imul(i, h2) >>> 0) % this.bitCount;
1463
+ }
1464
+ return positions;
1465
+ }
1466
+ };
1467
+
807
1468
  // src/subscription/subscription-manager.ts
808
1469
  var nextSubId = 0;
1470
+ var DEFAULT_BLOOM_THRESHOLD = 100;
1471
+ var DEFAULT_BLOOM_EXPECTED_ITEMS = 500;
1472
+ var DEFAULT_BLOOM_FALSE_POSITIVE_RATE = 0.01;
809
1473
  var SubscriptionManager = class {
810
1474
  subscriptions = /* @__PURE__ */ new Map();
811
1475
  pendingCollections = /* @__PURE__ */ new Set();
812
1476
  flushScheduled = false;
1477
+ // Bloom filter state
1478
+ bloomFilter = null;
1479
+ bloomDirty = false;
1480
+ bloomThreshold;
1481
+ bloomExpectedItems;
1482
+ bloomFalsePositiveRate;
1483
+ // Performance stats
1484
+ totalChecks = 0;
1485
+ bloomFilterHits = 0;
1486
+ bloomFilterMisses = 0;
1487
+ falsePositives = 0;
1488
+ totalCheckTimeMs = 0;
1489
+ constructor(options) {
1490
+ this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
1491
+ this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
1492
+ this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
1493
+ }
813
1494
  /**
814
1495
  * Register a new subscription.
815
1496
  *
@@ -828,8 +1509,10 @@ var SubscriptionManager = class {
828
1509
  lastResults: []
829
1510
  };
830
1511
  this.subscriptions.set(id, subscription);
1512
+ this.bloomDirty = true;
831
1513
  return () => {
832
1514
  this.subscriptions.delete(id);
1515
+ this.bloomDirty = true;
833
1516
  };
834
1517
  }
835
1518
  /**
@@ -849,6 +1532,7 @@ var SubscriptionManager = class {
849
1532
  lastResults: []
850
1533
  };
851
1534
  this.subscriptions.set(id, subscription);
1535
+ this.bloomDirty = true;
852
1536
  executeFn().then((results) => {
853
1537
  if (this.subscriptions.has(id)) {
854
1538
  subscription.lastResults = results;
@@ -857,6 +1541,7 @@ var SubscriptionManager = class {
857
1541
  });
858
1542
  return () => {
859
1543
  this.subscriptions.delete(id);
1544
+ this.bloomDirty = true;
860
1545
  };
861
1546
  }
862
1547
  /**
@@ -876,19 +1561,7 @@ var SubscriptionManager = class {
876
1561
  const collections = new Set(this.pendingCollections);
877
1562
  this.pendingCollections.clear();
878
1563
  this.flushScheduled = false;
879
- const affected = [];
880
- for (const sub of this.subscriptions.values()) {
881
- if (collections.has(sub.descriptor.collection)) {
882
- affected.push(sub);
883
- } else if (sub.descriptor.includeCollections) {
884
- for (const incCol of sub.descriptor.includeCollections) {
885
- if (collections.has(incCol)) {
886
- affected.push(sub);
887
- break;
888
- }
889
- }
890
- }
891
- }
1564
+ const affected = this.findAffectedSubscriptions(collections);
892
1565
  for (const sub of affected) {
893
1566
  try {
894
1567
  const newResults = await sub.executeFn();
@@ -907,11 +1580,118 @@ var SubscriptionManager = class {
907
1580
  this.subscriptions.clear();
908
1581
  this.pendingCollections.clear();
909
1582
  this.flushScheduled = false;
1583
+ this.bloomFilter = null;
1584
+ this.bloomDirty = false;
1585
+ this.totalChecks = 0;
1586
+ this.bloomFilterHits = 0;
1587
+ this.bloomFilterMisses = 0;
1588
+ this.falsePositives = 0;
1589
+ this.totalCheckTimeMs = 0;
910
1590
  }
911
1591
  /** Number of active subscriptions (for testing/debugging) */
912
1592
  get size() {
913
1593
  return this.subscriptions.size;
914
1594
  }
1595
+ /**
1596
+ * Get performance statistics for monitoring subscription checking efficiency.
1597
+ * Useful for DevTools integration and performance tuning.
1598
+ */
1599
+ getStats() {
1600
+ return {
1601
+ totalChecks: this.totalChecks,
1602
+ bloomFilterHits: this.bloomFilterHits,
1603
+ bloomFilterMisses: this.bloomFilterMisses,
1604
+ falsePositives: this.falsePositives,
1605
+ averageCheckTimeMs: this.totalChecks > 0 ? this.totalCheckTimeMs / this.totalChecks : 0,
1606
+ bloomFilterActive: this.isBloomActive(),
1607
+ subscriptionCount: this.subscriptions.size
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Check if bloom filter is currently active.
1612
+ * Active when subscription count meets or exceeds the threshold.
1613
+ */
1614
+ isBloomActive() {
1615
+ return this.subscriptions.size >= this.bloomThreshold;
1616
+ }
1617
+ /**
1618
+ * Find subscriptions affected by mutations to the given collections.
1619
+ * Uses two-level checking when bloom filter is active:
1620
+ *
1621
+ * Level 1: Bloom filter pre-check -- if no subscription depends on any
1622
+ * of the mutated collections, skip everything (O(k) per collection).
1623
+ *
1624
+ * Level 2: Precise check -- linear scan of subscriptions, matching
1625
+ * against the mutated collections.
1626
+ */
1627
+ findAffectedSubscriptions(collections) {
1628
+ const startTime = performance.now();
1629
+ this.totalChecks++;
1630
+ const useBloom = this.isBloomActive();
1631
+ if (useBloom) {
1632
+ if (this.bloomDirty || this.bloomFilter === null) {
1633
+ this.rebuildBloomFilter();
1634
+ }
1635
+ const filter = this.bloomFilter;
1636
+ if (filter !== null) {
1637
+ let anyPossibleMatch = false;
1638
+ for (const col of collections) {
1639
+ if (filter.mightContain(col)) {
1640
+ anyPossibleMatch = true;
1641
+ break;
1642
+ }
1643
+ }
1644
+ if (!anyPossibleMatch) {
1645
+ this.bloomFilterMisses++;
1646
+ this.totalCheckTimeMs += performance.now() - startTime;
1647
+ return [];
1648
+ }
1649
+ this.bloomFilterHits++;
1650
+ }
1651
+ }
1652
+ const affected = [];
1653
+ let anyPreciseMatch = false;
1654
+ for (const sub of this.subscriptions.values()) {
1655
+ if (collections.has(sub.descriptor.collection)) {
1656
+ affected.push(sub);
1657
+ anyPreciseMatch = true;
1658
+ } else if (sub.descriptor.includeCollections) {
1659
+ for (const incCol of sub.descriptor.includeCollections) {
1660
+ if (collections.has(incCol)) {
1661
+ affected.push(sub);
1662
+ anyPreciseMatch = true;
1663
+ break;
1664
+ }
1665
+ }
1666
+ }
1667
+ }
1668
+ if (useBloom && !anyPreciseMatch) {
1669
+ this.falsePositives++;
1670
+ }
1671
+ this.totalCheckTimeMs += performance.now() - startTime;
1672
+ return affected;
1673
+ }
1674
+ /**
1675
+ * Rebuild the bloom filter from all current subscriptions.
1676
+ * Adds collection-level dependencies for every subscription, plus
1677
+ * any included collection dependencies.
1678
+ */
1679
+ rebuildBloomFilter() {
1680
+ const filter = new SubscriptionBloomFilter(
1681
+ this.bloomExpectedItems,
1682
+ this.bloomFalsePositiveRate
1683
+ );
1684
+ for (const sub of this.subscriptions.values()) {
1685
+ filter.add(sub.descriptor.collection);
1686
+ if (sub.descriptor.includeCollections) {
1687
+ for (const incCol of sub.descriptor.includeCollections) {
1688
+ filter.add(incCol);
1689
+ }
1690
+ }
1691
+ }
1692
+ this.bloomFilter = filter;
1693
+ this.bloomDirty = false;
1694
+ }
915
1695
  scheduleFlush() {
916
1696
  if (this.flushScheduled) return;
917
1697
  this.flushScheduled = true;
@@ -930,6 +1710,322 @@ var SubscriptionManager = class {
930
1710
  }
931
1711
  };
932
1712
 
1713
+ // src/transaction/transaction-context.ts
1714
+ import {
1715
+ createOperation as createOperation3,
1716
+ generateUUIDv7 as generateUUIDv72,
1717
+ isAtomicOp as isAtomicOp2,
1718
+ resolveAtomicOp as resolveAtomicOp2,
1719
+ toAtomicOp as toAtomicOp2,
1720
+ validateRecord as validateRecord2
1721
+ } from "@korajs/core";
1722
+ var TransactionContext = class {
1723
+ transactionId;
1724
+ mutationName;
1725
+ buffer = [];
1726
+ committed = false;
1727
+ rolledBack = false;
1728
+ config;
1729
+ constructor(config) {
1730
+ this.config = config;
1731
+ this.transactionId = generateUUIDv72();
1732
+ }
1733
+ /**
1734
+ * Set a human-readable mutation name for this transaction.
1735
+ * Propagated to all operations for DevTools display.
1736
+ */
1737
+ setMutationName(name) {
1738
+ this.mutationName = name;
1739
+ }
1740
+ /**
1741
+ * Get the mutation name, if set.
1742
+ */
1743
+ getMutationName() {
1744
+ return this.mutationName;
1745
+ }
1746
+ /**
1747
+ * Get a collection accessor for buffered operations within this transaction.
1748
+ */
1749
+ collection(name) {
1750
+ const definition = this.config.schema.collections[name];
1751
+ if (!definition) {
1752
+ throw new Error(
1753
+ `Unknown collection "${name}". Available: ${Object.keys(this.config.schema.collections).join(", ")}`
1754
+ );
1755
+ }
1756
+ return {
1757
+ insert: (data) => this.insert(name, definition, data),
1758
+ update: (id, data) => this.update(name, definition, id, data),
1759
+ delete: (id) => this.deleteRecord(name, definition, id),
1760
+ findById: (id) => this.findById(name, definition, id)
1761
+ };
1762
+ }
1763
+ /**
1764
+ * Commit all buffered operations atomically.
1765
+ * Returns the list of operations and affected collections for subscription notification.
1766
+ */
1767
+ async commit() {
1768
+ if (this.committed) {
1769
+ throw new Error("Transaction already committed.");
1770
+ }
1771
+ if (this.rolledBack) {
1772
+ throw new Error("Transaction was rolled back and cannot be committed.");
1773
+ }
1774
+ this.committed = true;
1775
+ if (this.buffer.length === 0) {
1776
+ return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
1777
+ }
1778
+ const operations = [];
1779
+ const affectedCollections = /* @__PURE__ */ new Set();
1780
+ await this.config.adapter.transaction(async (tx) => {
1781
+ for (const entry of this.buffer) {
1782
+ for (const cmd of entry.commands) {
1783
+ await tx.execute(cmd.sql, cmd.params);
1784
+ }
1785
+ operations.push(entry.operation);
1786
+ affectedCollections.add(entry.collection);
1787
+ }
1788
+ });
1789
+ return { operations, affectedCollections };
1790
+ }
1791
+ /**
1792
+ * Mark the transaction as rolled back. No operations will be committed.
1793
+ */
1794
+ rollback() {
1795
+ this.rolledBack = true;
1796
+ this.buffer.length = 0;
1797
+ }
1798
+ /**
1799
+ * Get the transaction ID shared by all operations in this transaction.
1800
+ */
1801
+ getTransactionId() {
1802
+ return this.transactionId;
1803
+ }
1804
+ ensureActive() {
1805
+ if (this.committed) {
1806
+ throw new Error("Cannot perform operations on a committed transaction.");
1807
+ }
1808
+ if (this.rolledBack) {
1809
+ throw new Error("Cannot perform operations on a rolled-back transaction.");
1810
+ }
1811
+ }
1812
+ async insert(collectionName, definition, data) {
1813
+ this.ensureActive();
1814
+ const validated = validateRecord2(collectionName, definition, data, "insert");
1815
+ const recordId = generateUUIDv72();
1816
+ const now = Date.now();
1817
+ for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
1818
+ if (descriptor.auto && descriptor.kind === "timestamp") {
1819
+ validated[fieldName] = now;
1820
+ }
1821
+ }
1822
+ const sequenceNumber = this.config.getSequenceNumber();
1823
+ const operation = await createOperation3(
1824
+ {
1825
+ nodeId: this.config.nodeId,
1826
+ type: "insert",
1827
+ collection: collectionName,
1828
+ recordId,
1829
+ data: { ...validated },
1830
+ previousData: null,
1831
+ sequenceNumber,
1832
+ causalDeps: [],
1833
+ schemaVersion: this.config.schema.version,
1834
+ transactionId: this.transactionId,
1835
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
1836
+ },
1837
+ this.config.clock
1838
+ );
1839
+ const serializedData = serializeRecord(validated, definition.fields);
1840
+ const record = {
1841
+ id: recordId,
1842
+ ...serializedData,
1843
+ _created_at: now,
1844
+ _updated_at: now
1845
+ };
1846
+ const insertQuery = buildInsertQuery(collectionName, record);
1847
+ const opRow = serializeOperation(operation);
1848
+ const opInsert = buildInsertQuery(
1849
+ `_kora_ops_${collectionName}`,
1850
+ opRow
1851
+ );
1852
+ this.buffer.push({
1853
+ operation,
1854
+ collection: collectionName,
1855
+ commands: [
1856
+ { sql: insertQuery.sql, params: insertQuery.params },
1857
+ { sql: opInsert.sql, params: opInsert.params },
1858
+ {
1859
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1860
+ params: [this.config.nodeId, sequenceNumber]
1861
+ }
1862
+ ]
1863
+ });
1864
+ return {
1865
+ id: recordId,
1866
+ ...validated,
1867
+ createdAt: now,
1868
+ updatedAt: now
1869
+ };
1870
+ }
1871
+ async update(collectionName, definition, id, data) {
1872
+ this.ensureActive();
1873
+ const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
1874
+ if (!currentRecord) {
1875
+ throw new RecordNotFoundError(collectionName, id);
1876
+ }
1877
+ const validated = validateRecord2(collectionName, definition, data, "update");
1878
+ const now = Date.now();
1879
+ const previousData = {};
1880
+ const resolvedData = {};
1881
+ const atomicOps = {};
1882
+ for (const key of Object.keys(validated)) {
1883
+ const value = validated[key];
1884
+ previousData[key] = currentRecord[key];
1885
+ if (isAtomicOp2(value)) {
1886
+ resolvedData[key] = resolveAtomicOp2(currentRecord[key], value);
1887
+ atomicOps[key] = toAtomicOp2(value);
1888
+ } else {
1889
+ resolvedData[key] = value;
1890
+ }
1891
+ }
1892
+ const hasAtomicOps = Object.keys(atomicOps).length > 0;
1893
+ const sequenceNumber = this.config.getSequenceNumber();
1894
+ const operation = await createOperation3(
1895
+ {
1896
+ nodeId: this.config.nodeId,
1897
+ type: "update",
1898
+ collection: collectionName,
1899
+ recordId: id,
1900
+ data: { ...resolvedData },
1901
+ previousData,
1902
+ sequenceNumber,
1903
+ causalDeps: [],
1904
+ schemaVersion: this.config.schema.version,
1905
+ transactionId: this.transactionId,
1906
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
1907
+ ...hasAtomicOps ? { atomicOps } : {}
1908
+ },
1909
+ this.config.clock
1910
+ );
1911
+ const serializedChanges = serializeRecord(resolvedData, definition.fields);
1912
+ const updateQuery = buildUpdateQuery(collectionName, id, {
1913
+ ...serializedChanges,
1914
+ _updated_at: now
1915
+ });
1916
+ const opRow = serializeOperation(operation);
1917
+ const opInsert = buildInsertQuery(
1918
+ `_kora_ops_${collectionName}`,
1919
+ opRow
1920
+ );
1921
+ this.buffer.push({
1922
+ operation,
1923
+ collection: collectionName,
1924
+ commands: [
1925
+ { sql: updateQuery.sql, params: updateQuery.params },
1926
+ { sql: opInsert.sql, params: opInsert.params },
1927
+ {
1928
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1929
+ params: [this.config.nodeId, sequenceNumber]
1930
+ }
1931
+ ]
1932
+ });
1933
+ return {
1934
+ ...currentRecord,
1935
+ ...resolvedData,
1936
+ updatedAt: now
1937
+ };
1938
+ }
1939
+ async deleteRecord(collectionName, definition, id) {
1940
+ this.ensureActive();
1941
+ const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
1942
+ if (!currentRecord) {
1943
+ throw new RecordNotFoundError(collectionName, id);
1944
+ }
1945
+ const now = Date.now();
1946
+ const sequenceNumber = this.config.getSequenceNumber();
1947
+ const operation = await createOperation3(
1948
+ {
1949
+ nodeId: this.config.nodeId,
1950
+ type: "delete",
1951
+ collection: collectionName,
1952
+ recordId: id,
1953
+ data: null,
1954
+ previousData: null,
1955
+ sequenceNumber,
1956
+ causalDeps: [],
1957
+ schemaVersion: this.config.schema.version,
1958
+ transactionId: this.transactionId,
1959
+ ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
1960
+ },
1961
+ this.config.clock
1962
+ );
1963
+ const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
1964
+ const opRow = serializeOperation(operation);
1965
+ const opInsert = buildInsertQuery(
1966
+ `_kora_ops_${collectionName}`,
1967
+ opRow
1968
+ );
1969
+ this.buffer.push({
1970
+ operation,
1971
+ collection: collectionName,
1972
+ commands: [
1973
+ { sql: deleteQuery.sql, params: deleteQuery.params },
1974
+ { sql: opInsert.sql, params: opInsert.params },
1975
+ {
1976
+ sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
1977
+ params: [this.config.nodeId, sequenceNumber]
1978
+ }
1979
+ ]
1980
+ });
1981
+ }
1982
+ async findById(collectionName, definition, id) {
1983
+ return this.getEffectiveRecord(collectionName, definition, id);
1984
+ }
1985
+ /**
1986
+ * Get the effective state of a record, considering both the database and buffered operations.
1987
+ * Buffered inserts/updates take precedence over the database state.
1988
+ */
1989
+ async getEffectiveRecord(collectionName, definition, id) {
1990
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
1991
+ const entry = this.buffer[i];
1992
+ if (entry.collection === collectionName && entry.operation.recordId === id) {
1993
+ if (entry.operation.type === "delete") {
1994
+ return null;
1995
+ }
1996
+ break;
1997
+ }
1998
+ }
1999
+ const rows = await this.config.adapter.query(
2000
+ `SELECT * FROM ${collectionName} WHERE id = ? AND _deleted = 0`,
2001
+ [id]
2002
+ );
2003
+ let record = rows[0] ? deserializeRecord(rows[0], definition.fields) : null;
2004
+ for (const entry of this.buffer) {
2005
+ if (entry.collection !== collectionName || entry.operation.recordId !== id) {
2006
+ continue;
2007
+ }
2008
+ if (entry.operation.type === "insert" && entry.operation.data) {
2009
+ record = {
2010
+ id,
2011
+ ...entry.operation.data,
2012
+ createdAt: entry.operation.timestamp.wallTime,
2013
+ updatedAt: entry.operation.timestamp.wallTime
2014
+ };
2015
+ } else if (entry.operation.type === "update" && entry.operation.data && record) {
2016
+ record = {
2017
+ ...record,
2018
+ ...entry.operation.data,
2019
+ updatedAt: entry.operation.timestamp.wallTime
2020
+ };
2021
+ } else if (entry.operation.type === "delete") {
2022
+ record = null;
2023
+ }
2024
+ }
2025
+ return record;
2026
+ }
2027
+ };
2028
+
933
2029
  // src/store/store.ts
934
2030
  var Store = class {
935
2031
  opened = false;
@@ -939,6 +2035,7 @@ var Store = class {
939
2035
  clock = null;
940
2036
  collections = /* @__PURE__ */ new Map();
941
2037
  subscriptionManager = new SubscriptionManager();
2038
+ sequenceManager = null;
942
2039
  schema;
943
2040
  adapter;
944
2041
  configNodeId;
@@ -955,10 +2052,20 @@ var Store = class {
955
2052
  */
956
2053
  async open() {
957
2054
  await this.adapter.open(this.schema);
2055
+ await this.runMigrationsIfNeeded();
958
2056
  this.nodeId = await this.loadOrGenerateNodeId();
959
2057
  this.clock = new HybridLogicalClock2(this.nodeId);
2058
+ this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
960
2059
  this.sequenceNumber = await this.loadSequenceNumber();
961
2060
  this.versionVector = await this.loadVersionVector();
2061
+ const hasRelations = Object.keys(this.schema.relations).length > 0;
2062
+ const relationEnforcer = hasRelations ? new RelationEnforcer({
2063
+ schema: this.schema,
2064
+ adapter: this.adapter,
2065
+ clock: this.clock,
2066
+ nodeId: this.nodeId,
2067
+ getSequenceNumber: () => this.nextSequenceNumber()
2068
+ }) : void 0;
962
2069
  for (const [name, definition] of Object.entries(this.schema.collections)) {
963
2070
  const col = new Collection(
964
2071
  name,
@@ -973,7 +2080,8 @@ var Store = class {
973
2080
  if (this.emitter) {
974
2081
  this.emitter.emit({ type: "operation:created", operation });
975
2082
  }
976
- }
2083
+ },
2084
+ relationEnforcer
977
2085
  );
978
2086
  this.collections.set(name, col);
979
2087
  }
@@ -1010,7 +2118,14 @@ var Store = class {
1010
2118
  findById: (id) => col.findById(id),
1011
2119
  update: (id, data) => col.update(id, data),
1012
2120
  delete: (id) => col.delete(id),
1013
- where: (conditions) => new QueryBuilder(name, definition, this.adapter, this.subscriptionManager, conditions, this.schema)
2121
+ where: (conditions) => new QueryBuilder(
2122
+ name,
2123
+ definition,
2124
+ this.adapter,
2125
+ this.subscriptionManager,
2126
+ conditions,
2127
+ this.schema
2128
+ )
1014
2129
  };
1015
2130
  }
1016
2131
  /**
@@ -1125,11 +2240,148 @@ var Store = class {
1125
2240
  getSubscriptionManager() {
1126
2241
  return this.subscriptionManager;
1127
2242
  }
2243
+ /**
2244
+ * Get the sequence manager for offline-safe sequence generation.
2245
+ * @throws {StoreNotOpenError} If the store is not open
2246
+ */
2247
+ getSequenceManager() {
2248
+ this.ensureOpen();
2249
+ if (!this.sequenceManager) {
2250
+ throw new StoreNotOpenError();
2251
+ }
2252
+ return this.sequenceManager;
2253
+ }
2254
+ /**
2255
+ * Create a TransactionContext for atomic multi-collection operations.
2256
+ * The returned context buffers all mutations and commits them atomically.
2257
+ *
2258
+ * After commit, the caller is responsible for notifying subscriptions
2259
+ * and emitting events for each operation.
2260
+ */
2261
+ createTransaction() {
2262
+ this.ensureOpen();
2263
+ if (!this.clock) {
2264
+ throw new StoreNotOpenError();
2265
+ }
2266
+ return new TransactionContext({
2267
+ schema: this.schema,
2268
+ adapter: this.adapter,
2269
+ clock: this.clock,
2270
+ nodeId: this.nodeId,
2271
+ getSequenceNumber: () => this.nextSequenceNumber()
2272
+ });
2273
+ }
2274
+ /**
2275
+ * Execute a function within a transaction. All mutations performed on the
2276
+ * TransactionContext are committed atomically. Subscription notifications
2277
+ * are batched and fired after the commit.
2278
+ *
2279
+ * If the function throws, the transaction is rolled back and the error is re-thrown.
2280
+ *
2281
+ * @param fn - Function receiving a TransactionContext for buffered operations
2282
+ * @returns The operations that were committed
2283
+ */
2284
+ async transaction(fn) {
2285
+ const tx = this.createTransaction();
2286
+ try {
2287
+ await fn(tx);
2288
+ const { operations, affectedCollections } = await tx.commit();
2289
+ for (const op of operations) {
2290
+ this.subscriptionManager.notify(op.collection, op);
2291
+ if (this.emitter) {
2292
+ this.emitter.emit({ type: "operation:created", operation: op });
2293
+ }
2294
+ }
2295
+ return operations;
2296
+ } catch (error) {
2297
+ tx.rollback();
2298
+ throw error;
2299
+ }
2300
+ }
1128
2301
  nextSequenceNumber() {
1129
2302
  this.sequenceNumber++;
1130
2303
  this.versionVector.set(this.nodeId, this.sequenceNumber);
1131
2304
  return this.sequenceNumber;
1132
2305
  }
2306
+ /**
2307
+ * Check the stored schema version and run any pending migrations.
2308
+ * Migrations are applied in version order within a transaction.
2309
+ */
2310
+ async runMigrationsIfNeeded() {
2311
+ const storedVersion = await this.getStoredSchemaVersion();
2312
+ const targetVersion = this.schema.version;
2313
+ if (storedVersion >= targetVersion) {
2314
+ if (storedVersion === 0) {
2315
+ await this.adapter.execute(
2316
+ "INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
2317
+ [String(targetVersion)]
2318
+ );
2319
+ }
2320
+ return;
2321
+ }
2322
+ const migrations = this.schema.migrations ?? {};
2323
+ for (let v = storedVersion + 1; v <= targetVersion; v++) {
2324
+ const migration = migrations[v];
2325
+ if (!migration) continue;
2326
+ const sqlStatements = migrationStepsToSQL(migration.steps);
2327
+ for (const sql of sqlStatements) {
2328
+ try {
2329
+ await this.adapter.execute(sql);
2330
+ } catch (e) {
2331
+ const msg = e.message || "";
2332
+ if (!msg.includes("duplicate column name")) {
2333
+ throw e;
2334
+ }
2335
+ }
2336
+ }
2337
+ const backfillSteps = migration.steps.filter(
2338
+ (s) => s.type === "backfill"
2339
+ );
2340
+ for (const step of backfillSteps) {
2341
+ await this.runBackfill(step.collection, step.transform);
2342
+ }
2343
+ }
2344
+ await this.adapter.execute(
2345
+ "INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
2346
+ [String(targetVersion)]
2347
+ );
2348
+ }
2349
+ /**
2350
+ * Get the stored schema version from _kora_meta. Returns 0 if not set.
2351
+ */
2352
+ async getStoredSchemaVersion() {
2353
+ const rows = await this.adapter.query(
2354
+ "SELECT value FROM _kora_meta WHERE key = 'schema_version'"
2355
+ );
2356
+ return rows[0] ? Number(rows[0].value) : 0;
2357
+ }
2358
+ /**
2359
+ * Run a backfill transform on all records in a collection.
2360
+ * Reads all rows, applies the transform, and updates changed fields.
2361
+ */
2362
+ async runBackfill(collection, transform) {
2363
+ const rows = await this.adapter.query(
2364
+ `SELECT * FROM ${collection} WHERE _deleted = 0`
2365
+ );
2366
+ await this.adapter.transaction(async (tx) => {
2367
+ for (const row of rows) {
2368
+ const updates = transform(row);
2369
+ const fields = Object.keys(updates);
2370
+ if (fields.length === 0) continue;
2371
+ const setClauses = fields.map((f) => `${f} = ?`).join(", ");
2372
+ const values = fields.map((f) => {
2373
+ const val = updates[f];
2374
+ if (typeof val === "boolean") return val ? 1 : 0;
2375
+ if (Array.isArray(val) || typeof val === "object" && val !== null) {
2376
+ return JSON.stringify(val);
2377
+ }
2378
+ return val;
2379
+ });
2380
+ values.push(row.id);
2381
+ await tx.execute(`UPDATE ${collection} SET ${setClauses} WHERE id = ?`, values);
2382
+ }
2383
+ });
2384
+ }
1133
2385
  async loadOrGenerateNodeId() {
1134
2386
  if (this.configNodeId) {
1135
2387
  await this.adapter.execute(
@@ -1144,7 +2396,7 @@ var Store = class {
1144
2396
  if (rows[0]) {
1145
2397
  return rows[0].value;
1146
2398
  }
1147
- const newNodeId = generateUUIDv72();
2399
+ const newNodeId = generateUUIDv73();
1148
2400
  await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
1149
2401
  newNodeId
1150
2402
  ]);
@@ -1176,19 +2428,25 @@ var Store = class {
1176
2428
  export {
1177
2429
  AdapterError,
1178
2430
  Collection,
2431
+ InvalidStateTransitionError,
1179
2432
  PersistenceError,
1180
2433
  QueryBuilder,
1181
2434
  QueryError,
1182
2435
  RecordNotFoundError,
2436
+ SequenceManager,
1183
2437
  Store,
1184
2438
  StoreNotOpenError,
2439
+ SubscriptionBloomFilter,
1185
2440
  SubscriptionManager,
2441
+ TransactionContext,
1186
2442
  WorkerInitError,
1187
2443
  WorkerTimeoutError,
1188
2444
  decodeRichtext,
1189
2445
  encodeRichtext,
1190
2446
  pluralize,
1191
2447
  richtextToPlainText,
1192
- singularize
2448
+ singularize,
2449
+ validateStateTransition,
2450
+ validateUpdateStateMachine
1193
2451
  };
1194
2452
  //# sourceMappingURL=index.js.map