@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/adapters/better-sqlite3.cjs +1 -1
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.js +1 -1
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +3 -1
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.js +3 -1
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/index.cjs +1283 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +544 -8
- package/dist/index.d.ts +544 -8
- package/dist/index.js +1287 -29
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
111
|
+
var import_core8 = require("@korajs/core");
|
|
106
112
|
|
|
107
113
|
// src/collection/collection.ts
|
|
108
|
-
var
|
|
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(
|
|
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:
|
|
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
|
|
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,
|
|
424
|
-
const recordId = (0,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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: { ...
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
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,
|
|
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,
|
|
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
|