@mindees/data 0.1.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/LICENSE +31 -0
- package/README.md +70 -0
- package/dist/collection.d.ts +76 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/collection.js +171 -0
- package/dist/collection.js.map +1 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/hlc.d.ts +79 -0
- package/dist/hlc.d.ts.map +1 -0
- package/dist/hlc.js +95 -0
- package/dist/hlc.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/lww.d.ts +38 -0
- package/dist/lww.d.ts.map +1 -0
- package/dist/lww.js +105 -0
- package/dist/lww.js.map +1 -0
- package/dist/or-set.d.ts +33 -0
- package/dist/or-set.d.ts.map +1 -0
- package/dist/or-set.js +71 -0
- package/dist/or-set.js.map +1 -0
- package/dist/persist.d.ts +25 -0
- package/dist/persist.d.ts.map +1 -0
- package/dist/persist.js +16 -0
- package/dist/persist.js.map +1 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +38 -0
- package/dist/server.js.map +1 -0
- package/dist/sync.d.ts +108 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +164 -0
- package/dist/sync.js.map +1 -0
- package/dist/version-vector.d.ts +28 -0
- package/dist/version-vector.d.ts.map +1 -0
- package/dist/version-vector.js +40 -0
- package/dist/version-vector.js.map +1 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
MindeesNative is dual-licensed under either of:
|
|
4
|
+
|
|
5
|
+
- **Apache License, Version 2.0** ([LICENSE-APACHE](./LICENSE-APACHE) or
|
|
6
|
+
<https://www.apache.org/licenses/LICENSE-2.0>)
|
|
7
|
+
- **MIT license** ([LICENSE-MIT](./LICENSE-MIT) or
|
|
8
|
+
<https://opensource.org/licenses/MIT>)
|
|
9
|
+
|
|
10
|
+
at your option.
|
|
11
|
+
|
|
12
|
+
This `MIT OR Apache-2.0` dual-license is the same model used by the Rust
|
|
13
|
+
ecosystem and many modern open-source projects. It gives downstream users
|
|
14
|
+
maximum flexibility: the MIT option is short and permissive, while the Apache
|
|
15
|
+
option adds an explicit patent grant.
|
|
16
|
+
|
|
17
|
+
## SPDX identifier
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
SPDX-License-Identifier: MIT OR Apache-2.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Contribution
|
|
24
|
+
|
|
25
|
+
Unless you explicitly state otherwise, any contribution intentionally
|
|
26
|
+
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
|
27
|
+
license, shall be dual-licensed as above, without any additional terms or
|
|
28
|
+
conditions.
|
|
29
|
+
|
|
30
|
+
Contributions are accepted under the **Developer Certificate of Origin (DCO)**.
|
|
31
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on signing off your commits.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @mindees/data
|
|
2
|
+
|
|
3
|
+
**Continuum** โ a local-first reactive store + sync for MindeesNative.
|
|
4
|
+
|
|
5
|
+
> Status: ๐งช **Experimental** โ Continuum Phases 10Aโ10F are implemented and tested:
|
|
6
|
+
> the reactive document store, Hybrid Logical Clock causality, CRDT conflict resolution
|
|
7
|
+
> (per-field LWW + add-wins OR-Set), a local-first delta-sync engine where two peers
|
|
8
|
+
> converge offline, a capability-injected reference sync server, and a persistence
|
|
9
|
+
> contract with export/restore. Native durable adapters, production sync hardening, and
|
|
10
|
+
> CRDT-library/rich-text interop are ๐ฌ research tracks. See the repository
|
|
11
|
+
> [STATUS.md](../../STATUS.md).
|
|
12
|
+
|
|
13
|
+
## What works today
|
|
14
|
+
|
|
15
|
+
`createCollection<T>()` โ a reactive, in-memory collection of records keyed by `id`,
|
|
16
|
+
built on `@mindees/core` signals (zero third-party dependencies):
|
|
17
|
+
|
|
18
|
+
- **Fine-grained reactive reads** โ `get(id)` / `has(id)` subscribe to *that record*
|
|
19
|
+
(per-record version signal); `all()` / `where(pred)` / `size()` subscribe to the
|
|
20
|
+
collection. A query is just a `() => T[]` accessor the renderer treats as a reactive
|
|
21
|
+
region, so `get`/`update` re-render exactly what changed. `snapshot()` reads
|
|
22
|
+
non-reactively.
|
|
23
|
+
- **Atomic mutations** โ `insert` (insert-only), `upsert`, `update(id, patch | fn)`,
|
|
24
|
+
`delete`, `clear`, and `tx(fn)` to batch many mutations into one notification.
|
|
25
|
+
Records are treated as immutable (update produces a new object).
|
|
26
|
+
- **Optimistic changes** โ `optimistic(fn)` applies immediately and returns
|
|
27
|
+
`{ commit(), rollback() }`; `rollback()` restores the prior state in one batch.
|
|
28
|
+
- **Stable errors** โ `DataError` with a `DataErrorCode`
|
|
29
|
+
(`DUPLICATE_ID` / `RECORD_NOT_FOUND` / `ID_IMMUTABLE`).
|
|
30
|
+
|
|
31
|
+
The package also ships the sync and durability pieces that build on the store:
|
|
32
|
+
|
|
33
|
+
- **Causality primitives** โ `createClock`, HLC encode/decode/compare helpers, and
|
|
34
|
+
version vectors for drift-guarded causal ordering.
|
|
35
|
+
- **CRDT conflict helpers** โ per-field LWW Register/Map and add-wins OR-Set merge
|
|
36
|
+
utilities, property-tested for convergence.
|
|
37
|
+
- **Delta sync** โ `createSyncEngine`, `createMutationLog`, `createMemoryHub`, and the
|
|
38
|
+
`SyncTransport` contract for optimistic local writes plus push/pull/merge.
|
|
39
|
+
- **Reference sync server** โ `createSyncServer` from `@mindees/data/server` over an
|
|
40
|
+
injected `OpLogStore`, with a runnable `node:http` example in
|
|
41
|
+
[`examples/data-sync-server`](../../examples/data-sync-server).
|
|
42
|
+
- **Persistence contract** โ `Persistence`, `createMemoryPersistence`, and engine
|
|
43
|
+
`export()`/restore so replicas keep stable identity across restarts.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createCollection } from '@mindees/data'
|
|
47
|
+
import { effect } from '@mindees/core'
|
|
48
|
+
|
|
49
|
+
const todos = createCollection<{ id: string; text: string; done: boolean }>()
|
|
50
|
+
todos.insert({ id: 't1', text: 'Ship Continuum', done: false })
|
|
51
|
+
|
|
52
|
+
// A fine-grained reactive query โ re-runs only when t1 changes:
|
|
53
|
+
effect(() => console.log(todos.get('t1')?.done))
|
|
54
|
+
|
|
55
|
+
todos.update('t1', { done: true }) // logs: true
|
|
56
|
+
|
|
57
|
+
// Optimistic update with rollback on server reject:
|
|
58
|
+
const change = todos.optimistic(() => todos.update('t1', { text: 'edited' }))
|
|
59
|
+
// โฆlater: change.commit() // or change.rollback()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Design rationale: [ADR-0012](../../docs/adr/0012-continuum-reactive-store.md),
|
|
63
|
+
[ADR-0013](../../docs/adr/0013-continuum-hlc-causality.md),
|
|
64
|
+
[ADR-0014](../../docs/adr/0014-continuum-crdt.md),
|
|
65
|
+
[ADR-0015](../../docs/adr/0015-continuum-sync-engine.md), and
|
|
66
|
+
[ADR-0016](../../docs/adr/0016-continuum-server-persistence.md).
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
`MIT OR Apache-2.0`
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/collection.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The Continuum reactive document store โ a signals-native, in-memory collection of
|
|
4
|
+
* records keyed by `id`. Reads subscribe fine-grained (per record + per collection),
|
|
5
|
+
* mutations are atomic + coalescing, and optimistic changes can be rolled back. It is
|
|
6
|
+
* the substrate the later sub-phases extend: per-field CRDT merge (10C) operates on a
|
|
7
|
+
* record's fields, and the sync queue (10D) captures these mutations as ops.
|
|
8
|
+
*
|
|
9
|
+
* Built on `@mindees/core` signals only (zero third-party deps), using the same
|
|
10
|
+
* `signal(0, { equals: false })` notify-source idiom as core/router โ so a query is
|
|
11
|
+
* just a `() => T[]` accessor the renderer already treats as a reactive region.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/adr/0012-continuum-reactive-store.md`.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
/** A record identifier. */
|
|
18
|
+
type Id = string | number;
|
|
19
|
+
/** Options for {@link createCollection}. */
|
|
20
|
+
interface CollectionOptions<T> {
|
|
21
|
+
/** Records to seed the collection with (ids must be unique). */
|
|
22
|
+
readonly initial?: readonly T[];
|
|
23
|
+
}
|
|
24
|
+
/** A handle to an optimistic change (see {@link Collection.optimistic}). */
|
|
25
|
+
interface OptimisticChange {
|
|
26
|
+
/** Keep the change (drop the recorded inverse). */
|
|
27
|
+
commit(): void;
|
|
28
|
+
/** Undo the change, restoring the prior state in one batch. */
|
|
29
|
+
rollback(): void;
|
|
30
|
+
}
|
|
31
|
+
/** A reactive, in-memory collection of records keyed by `id`. */
|
|
32
|
+
interface Collection<T extends {
|
|
33
|
+
id: Id;
|
|
34
|
+
}> {
|
|
35
|
+
/** Reactively read a record by id (subscribes to that record). */
|
|
36
|
+
get(id: Id): T | undefined;
|
|
37
|
+
/** Reactively test membership (subscribes to that record). */
|
|
38
|
+
has(id: Id): boolean;
|
|
39
|
+
/** Reactively read all records (subscribes to the collection). */
|
|
40
|
+
all(): readonly T[];
|
|
41
|
+
/** Reactively read the records matching `predicate` (linear scan; subscribes to the collection). */
|
|
42
|
+
where(predicate: (record: T) => boolean): readonly T[];
|
|
43
|
+
/** Reactively read the record count (subscribes to the collection). */
|
|
44
|
+
size(): number;
|
|
45
|
+
/** A non-reactive snapshot of all records (does not subscribe). */
|
|
46
|
+
snapshot(): readonly T[];
|
|
47
|
+
/** Insert a new record. Throws `DUPLICATE_ID` if the id already exists. */
|
|
48
|
+
insert(record: T): void;
|
|
49
|
+
/** Insert or replace a record. */
|
|
50
|
+
upsert(record: T): void;
|
|
51
|
+
/**
|
|
52
|
+
* Patch an existing record (object patch or updater fn). Throws `RECORD_NOT_FOUND`
|
|
53
|
+
* if absent and `ID_IMMUTABLE` if the patch changes `id`. The updater fn must return
|
|
54
|
+
* a NEW record โ do not mutate `prev` in place (it backs optimistic rollback).
|
|
55
|
+
*/
|
|
56
|
+
update(id: Id, patch: Partial<T> | ((prev: T) => T)): void;
|
|
57
|
+
/** Delete a record. Returns whether it existed. */
|
|
58
|
+
delete(id: Id): boolean;
|
|
59
|
+
/** Remove every record. */
|
|
60
|
+
clear(): void;
|
|
61
|
+
/** Run several mutations as one atomic, single-notification transaction. */
|
|
62
|
+
tx<R>(fn: () => R): R;
|
|
63
|
+
/**
|
|
64
|
+
* Apply mutations now, returning a handle to `commit()` or `rollback()` them. The
|
|
65
|
+
* block is **atomic** (a throw inside `fn` rolls back its partial mutations and
|
|
66
|
+
* rethrows) and **not reentrant** (throws `OPTIMISTIC_NESTED` if nested).
|
|
67
|
+
*/
|
|
68
|
+
optimistic(fn: () => void): OptimisticChange;
|
|
69
|
+
}
|
|
70
|
+
/** Create a reactive {@link Collection}. */
|
|
71
|
+
declare function createCollection<T extends {
|
|
72
|
+
id: Id;
|
|
73
|
+
}>(options?: CollectionOptions<T>): Collection<T>;
|
|
74
|
+
//#endregion
|
|
75
|
+
export { Collection, CollectionOptions, Id, OptimisticChange, createCollection };
|
|
76
|
+
//# sourceMappingURL=collection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collection.d.ts","names":[],"sources":["../src/collection.ts"],"mappings":";;AAoBA;;;;AAAc;AAGd;;;;;;;;AAE+B;AAI/B;AAAA,KATY,EAAA;;UAGK,iBAAA;EAUP;EAAA,SARC,OAAA,YAAmB,CAAC;AAAA;;UAId,gBAAA;EAUP;EARR,MAAA;EAUQ;EARR,QAAQ;AAAA;;UAIO,UAAA;EAAuB,EAAA,EAAI,EAAA;AAAA;EAsB/B;EApBX,GAAA,CAAI,EAAA,EAAI,EAAA,GAAK,CAAA;EAoBS;EAlBtB,GAAA,CAAI,EAAA,EAAI,EAAA;EAkByC;EAhBjD,GAAA,aAAgB,CAAA;EAsBA;EApBhB,KAAA,CAAM,SAAA,GAAY,MAAA,EAAQ,CAAA,wBAAyB,CAAA;EA0BvB;EAxB5B,IAAA;EAwB4C;EAtB5C,QAAA,aAAqB,CAAA;EAZiB;EActC,MAAA,CAAO,MAAA,EAAQ,CAAA;EAZf;EAcA,MAAA,CAAO,MAAA,EAAQ,CAAA;EAdX;;;;;EAoBJ,MAAA,CAAO,EAAA,EAAI,EAAA,EAAI,KAAA,EAAO,OAAA,CAAQ,CAAA,MAAO,IAAA,EAAM,CAAA,KAAM,CAAA;EAhBjC;EAkBhB,MAAA,CAAO,EAAA,EAAI,EAAA;EAhBe;EAkB1B,KAAA;EAlBM;EAoBN,EAAA,IAAM,EAAA,QAAU,CAAA,GAAI,CAAA;EAlBpB;;;;;EAwBA,UAAA,CAAW,EAAA,eAAiB,gBAAA;AAAA;;iBAId,gBAAA;EAA6B,EAAA,EAAI,EAAA;AAAA,GAC/C,OAAA,GAAU,iBAAA,CAAkB,CAAA,IAC3B,UAAA,CAAW,CAAA"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { DataError } from "./errors.js";
|
|
2
|
+
import { batch, signal } from "@mindees/core";
|
|
3
|
+
//#region src/collection.ts
|
|
4
|
+
/**
|
|
5
|
+
* The Continuum reactive document store โ a signals-native, in-memory collection of
|
|
6
|
+
* records keyed by `id`. Reads subscribe fine-grained (per record + per collection),
|
|
7
|
+
* mutations are atomic + coalescing, and optimistic changes can be rolled back. It is
|
|
8
|
+
* the substrate the later sub-phases extend: per-field CRDT merge (10C) operates on a
|
|
9
|
+
* record's fields, and the sync queue (10D) captures these mutations as ops.
|
|
10
|
+
*
|
|
11
|
+
* Built on `@mindees/core` signals only (zero third-party deps), using the same
|
|
12
|
+
* `signal(0, { equals: false })` notify-source idiom as core/router โ so a query is
|
|
13
|
+
* just a `() => T[]` accessor the renderer already treats as a reactive region.
|
|
14
|
+
*
|
|
15
|
+
* See `docs/adr/0012-continuum-reactive-store.md`.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/** Create a reactive {@link Collection}. */
|
|
20
|
+
function createCollection(options) {
|
|
21
|
+
const records = /* @__PURE__ */ new Map();
|
|
22
|
+
const recordVersions = /* @__PURE__ */ new Map();
|
|
23
|
+
const collectionVersion = signal(0, { equals: false });
|
|
24
|
+
let undoLog = null;
|
|
25
|
+
const recordVersion = (id) => {
|
|
26
|
+
let v = recordVersions.get(id);
|
|
27
|
+
if (!v) {
|
|
28
|
+
v = signal(0, { equals: false });
|
|
29
|
+
recordVersions.set(id, v);
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
};
|
|
33
|
+
const recordInverse = (inverse) => {
|
|
34
|
+
if (undoLog) undoLog.push(inverse);
|
|
35
|
+
};
|
|
36
|
+
const doSet = (record) => {
|
|
37
|
+
records.set(record.id, record);
|
|
38
|
+
recordVersion(record.id).update((n) => n + 1);
|
|
39
|
+
collectionVersion.update((n) => n + 1);
|
|
40
|
+
};
|
|
41
|
+
const doDelete = (id) => {
|
|
42
|
+
records.delete(id);
|
|
43
|
+
recordVersion(id).update((n) => n + 1);
|
|
44
|
+
recordVersions.delete(id);
|
|
45
|
+
collectionVersion.update((n) => n + 1);
|
|
46
|
+
};
|
|
47
|
+
if (options?.initial) for (const record of options.initial) {
|
|
48
|
+
if (records.has(record.id)) throw new DataError("DUPLICATE_ID", `duplicate id ${String(record.id)} in initial records`);
|
|
49
|
+
records.set(record.id, record);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
get(id) {
|
|
53
|
+
if (records.has(id)) {
|
|
54
|
+
recordVersion(id)();
|
|
55
|
+
return records.get(id);
|
|
56
|
+
}
|
|
57
|
+
collectionVersion();
|
|
58
|
+
},
|
|
59
|
+
has(id) {
|
|
60
|
+
if (records.has(id)) {
|
|
61
|
+
recordVersion(id)();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
collectionVersion();
|
|
65
|
+
return false;
|
|
66
|
+
},
|
|
67
|
+
all() {
|
|
68
|
+
collectionVersion();
|
|
69
|
+
return [...records.values()];
|
|
70
|
+
},
|
|
71
|
+
where(predicate) {
|
|
72
|
+
collectionVersion();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const record of records.values()) if (predicate(record)) out.push(record);
|
|
75
|
+
return out;
|
|
76
|
+
},
|
|
77
|
+
size() {
|
|
78
|
+
collectionVersion();
|
|
79
|
+
return records.size;
|
|
80
|
+
},
|
|
81
|
+
snapshot() {
|
|
82
|
+
return [...records.values()];
|
|
83
|
+
},
|
|
84
|
+
insert(record) {
|
|
85
|
+
if (records.has(record.id)) throw new DataError("DUPLICATE_ID", `record ${String(record.id)} already exists`);
|
|
86
|
+
batch(() => {
|
|
87
|
+
recordInverse(() => doDelete(record.id));
|
|
88
|
+
doSet(record);
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
upsert(record) {
|
|
92
|
+
const prev = records.get(record.id);
|
|
93
|
+
batch(() => {
|
|
94
|
+
recordInverse(prev !== void 0 ? () => doSet(prev) : () => doDelete(record.id));
|
|
95
|
+
doSet(record);
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
update(id, patch) {
|
|
99
|
+
const prev = records.get(id);
|
|
100
|
+
if (prev === void 0) throw new DataError("RECORD_NOT_FOUND", `no record ${String(id)} to update`);
|
|
101
|
+
if (typeof patch !== "function" && "id" in patch && patch.id !== id) throw new DataError("ID_IMMUTABLE", `cannot change the id of record ${String(id)}`);
|
|
102
|
+
const next = typeof patch === "function" ? patch(prev) : {
|
|
103
|
+
...prev,
|
|
104
|
+
...patch,
|
|
105
|
+
id
|
|
106
|
+
};
|
|
107
|
+
if (next.id !== id) throw new DataError("ID_IMMUTABLE", `cannot change the id of record ${String(id)}`);
|
|
108
|
+
batch(() => {
|
|
109
|
+
recordInverse(() => doSet(prev));
|
|
110
|
+
doSet(next);
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
delete(id) {
|
|
114
|
+
const prev = records.get(id);
|
|
115
|
+
if (prev === void 0) return false;
|
|
116
|
+
batch(() => {
|
|
117
|
+
recordInverse(() => doSet(prev));
|
|
118
|
+
doDelete(id);
|
|
119
|
+
});
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
clear() {
|
|
123
|
+
if (records.size === 0) return;
|
|
124
|
+
const prevAll = [...records.values()];
|
|
125
|
+
batch(() => {
|
|
126
|
+
recordInverse(() => {
|
|
127
|
+
for (const record of prevAll) doSet(record);
|
|
128
|
+
});
|
|
129
|
+
for (const id of [...records.keys()]) doDelete(id);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
tx(fn) {
|
|
133
|
+
return batch(fn);
|
|
134
|
+
},
|
|
135
|
+
optimistic(fn) {
|
|
136
|
+
if (undoLog !== null) throw new DataError("OPTIMISTIC_NESTED", "optimistic() cannot be nested");
|
|
137
|
+
const log = [];
|
|
138
|
+
const replay = () => {
|
|
139
|
+
batch(() => {
|
|
140
|
+
for (let i = log.length - 1; i >= 0; i--) log[i]?.();
|
|
141
|
+
});
|
|
142
|
+
log.length = 0;
|
|
143
|
+
};
|
|
144
|
+
undoLog = log;
|
|
145
|
+
try {
|
|
146
|
+
batch(fn);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
replay();
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
undoLog = null;
|
|
152
|
+
}
|
|
153
|
+
let settled = false;
|
|
154
|
+
return {
|
|
155
|
+
commit() {
|
|
156
|
+
settled = true;
|
|
157
|
+
log.length = 0;
|
|
158
|
+
},
|
|
159
|
+
rollback() {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
replay();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
export { createCollection };
|
|
170
|
+
|
|
171
|
+
//# sourceMappingURL=collection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collection.js","names":[],"sources":["../src/collection.ts"],"sourcesContent":["/**\n * The Continuum reactive document store โ a signals-native, in-memory collection of\n * records keyed by `id`. Reads subscribe fine-grained (per record + per collection),\n * mutations are atomic + coalescing, and optimistic changes can be rolled back. It is\n * the substrate the later sub-phases extend: per-field CRDT merge (10C) operates on a\n * record's fields, and the sync queue (10D) captures these mutations as ops.\n *\n * Built on `@mindees/core` signals only (zero third-party deps), using the same\n * `signal(0, { equals: false })` notify-source idiom as core/router โ so a query is\n * just a `() => T[]` accessor the renderer already treats as a reactive region.\n *\n * See `docs/adr/0012-continuum-reactive-store.md`.\n *\n * @module\n */\n\nimport { batch, type Signal, signal } from '@mindees/core'\nimport { DataError } from './errors'\n\n/** A record identifier. */\nexport type Id = string | number\n\n/** Options for {@link createCollection}. */\nexport interface CollectionOptions<T> {\n /** Records to seed the collection with (ids must be unique). */\n readonly initial?: readonly T[]\n}\n\n/** A handle to an optimistic change (see {@link Collection.optimistic}). */\nexport interface OptimisticChange {\n /** Keep the change (drop the recorded inverse). */\n commit(): void\n /** Undo the change, restoring the prior state in one batch. */\n rollback(): void\n}\n\n/** A reactive, in-memory collection of records keyed by `id`. */\nexport interface Collection<T extends { id: Id }> {\n /** Reactively read a record by id (subscribes to that record). */\n get(id: Id): T | undefined\n /** Reactively test membership (subscribes to that record). */\n has(id: Id): boolean\n /** Reactively read all records (subscribes to the collection). */\n all(): readonly T[]\n /** Reactively read the records matching `predicate` (linear scan; subscribes to the collection). */\n where(predicate: (record: T) => boolean): readonly T[]\n /** Reactively read the record count (subscribes to the collection). */\n size(): number\n /** A non-reactive snapshot of all records (does not subscribe). */\n snapshot(): readonly T[]\n /** Insert a new record. Throws `DUPLICATE_ID` if the id already exists. */\n insert(record: T): void\n /** Insert or replace a record. */\n upsert(record: T): void\n /**\n * Patch an existing record (object patch or updater fn). Throws `RECORD_NOT_FOUND`\n * if absent and `ID_IMMUTABLE` if the patch changes `id`. The updater fn must return\n * a NEW record โ do not mutate `prev` in place (it backs optimistic rollback).\n */\n update(id: Id, patch: Partial<T> | ((prev: T) => T)): void\n /** Delete a record. Returns whether it existed. */\n delete(id: Id): boolean\n /** Remove every record. */\n clear(): void\n /** Run several mutations as one atomic, single-notification transaction. */\n tx<R>(fn: () => R): R\n /**\n * Apply mutations now, returning a handle to `commit()` or `rollback()` them. The\n * block is **atomic** (a throw inside `fn` rolls back its partial mutations and\n * rethrows) and **not reentrant** (throws `OPTIMISTIC_NESTED` if nested).\n */\n optimistic(fn: () => void): OptimisticChange\n}\n\n/** Create a reactive {@link Collection}. */\nexport function createCollection<T extends { id: Id }>(\n options?: CollectionOptions<T>,\n): Collection<T> {\n const records = new Map<Id, T>()\n const recordVersions = new Map<Id, Signal<number>>()\n const collectionVersion = signal(0, { equals: false })\n // When set, mutations push an inverse here (drives optimistic rollback).\n let undoLog: Array<() => void> | null = null\n\n const recordVersion = (id: Id): Signal<number> => {\n let v = recordVersions.get(id)\n if (!v) {\n v = signal(0, { equals: false })\n recordVersions.set(id, v)\n }\n return v\n }\n\n const recordInverse = (inverse: () => void): void => {\n if (undoLog) undoLog.push(inverse)\n }\n\n // Raw write + reactivity (no inverse capture). Used by mutations and inverses alike.\n const doSet = (record: T): void => {\n records.set(record.id, record)\n recordVersion(record.id).update((n) => n + 1)\n collectionVersion.update((n) => n + 1)\n }\n const doDelete = (id: Id): void => {\n records.delete(id)\n recordVersion(id).update((n) => n + 1) // notify observers BEFORE GC\n recordVersions.delete(id) // GC the version signal so removed records don't leak\n collectionVersion.update((n) => n + 1)\n }\n\n if (options?.initial) {\n for (const record of options.initial) {\n if (records.has(record.id)) {\n throw new DataError('DUPLICATE_ID', `duplicate id ${String(record.id)} in initial records`)\n }\n records.set(record.id, record) // no observers yet at construction โ skip reactivity\n }\n }\n\n return {\n get(id) {\n // Present id โ subscribe fine-grained to that record. Absent id โ subscribe to\n // the collection version (a missing id can only appear via a mutation, which bumps\n // it), so reads of never-existing ids never materialize a permanent per-record signal.\n if (records.has(id)) {\n recordVersion(id)()\n return records.get(id)\n }\n collectionVersion()\n return undefined\n },\n has(id) {\n if (records.has(id)) {\n recordVersion(id)()\n return true\n }\n collectionVersion()\n return false\n },\n all() {\n collectionVersion()\n return [...records.values()]\n },\n where(predicate) {\n collectionVersion()\n const out: T[] = []\n for (const record of records.values()) if (predicate(record)) out.push(record)\n return out\n },\n size() {\n collectionVersion()\n return records.size\n },\n snapshot() {\n return [...records.values()]\n },\n\n insert(record) {\n if (records.has(record.id)) {\n throw new DataError('DUPLICATE_ID', `record ${String(record.id)} already exists`)\n }\n batch(() => {\n recordInverse(() => doDelete(record.id))\n doSet(record)\n })\n },\n\n upsert(record) {\n const prev = records.get(record.id)\n batch(() => {\n recordInverse(prev !== undefined ? () => doSet(prev) : () => doDelete(record.id))\n doSet(record)\n })\n },\n\n update(id, patch) {\n const prev = records.get(id)\n if (prev === undefined) {\n throw new DataError('RECORD_NOT_FOUND', `no record ${String(id)} to update`)\n }\n // Reject a foreign id in either form (the object-patch spread would otherwise\n // silently drop it, making the two forms inconsistent).\n if (typeof patch !== 'function' && 'id' in patch && (patch as { id?: Id }).id !== id) {\n throw new DataError('ID_IMMUTABLE', `cannot change the id of record ${String(id)}`)\n }\n const next =\n typeof patch === 'function'\n ? (patch as (p: T) => T)(prev)\n : ({ ...prev, ...patch, id } as T)\n if (next.id !== id) {\n throw new DataError('ID_IMMUTABLE', `cannot change the id of record ${String(id)}`)\n }\n batch(() => {\n recordInverse(() => doSet(prev))\n doSet(next)\n })\n },\n\n delete(id) {\n const prev = records.get(id)\n if (prev === undefined) return false\n batch(() => {\n recordInverse(() => doSet(prev))\n doDelete(id)\n })\n return true\n },\n\n clear() {\n if (records.size === 0) return\n const prevAll = [...records.values()]\n batch(() => {\n recordInverse(() => {\n for (const record of prevAll) doSet(record)\n })\n for (const id of [...records.keys()]) doDelete(id)\n })\n },\n\n tx(fn) {\n return batch(fn)\n },\n\n optimistic(fn) {\n // Not reentrant: a nested optimistic block's inverses would not reach the outer\n // log, so the outer rollback couldn't restore them. Fail fast instead.\n if (undoLog !== null) {\n throw new DataError('OPTIMISTIC_NESTED', 'optimistic() cannot be nested')\n }\n const log: Array<() => void> = []\n const replay = (): void => {\n batch(() => {\n for (let i = log.length - 1; i >= 0; i--) log[i]?.()\n })\n log.length = 0\n }\n undoLog = log\n try {\n batch(fn)\n } catch (error) {\n // Keep optimistic application all-or-nothing: undo partial mutations, then rethrow.\n replay()\n throw error\n } finally {\n undoLog = null\n }\n let settled = false\n return {\n commit() {\n settled = true\n log.length = 0 // release the captured inverses (and their snapshots)\n },\n rollback() {\n if (settled) return\n settled = true\n replay()\n },\n }\n },\n }\n}\n"],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/errors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Errors for `@mindees/data` (Continuum).
|
|
4
|
+
*
|
|
5
|
+
* Every failure carries a stable {@link DataErrorCode} so callers can branch on the
|
|
6
|
+
* cause without string-matching messages.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
/** Stable code identifying why a Continuum operation failed. */
|
|
11
|
+
type DataErrorCode = /** `insert` was given an id that already exists (use `upsert` to replace). */'DUPLICATE_ID' /** `update` referenced an id that is not in the collection. */ | 'RECORD_NOT_FOUND' /** A mutation tried to change a record's `id` (ids are immutable). */ | 'ID_IMMUTABLE' /** `optimistic()` was called inside another `optimistic()` block (not reentrant). */ | 'OPTIMISTIC_NESTED';
|
|
12
|
+
/** A Continuum data error carrying a stable {@link DataErrorCode}. */
|
|
13
|
+
declare class DataError extends Error {
|
|
14
|
+
/** Stable, machine-readable cause. */
|
|
15
|
+
readonly code: DataErrorCode;
|
|
16
|
+
constructor(code: DataErrorCode, message: string);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { DataError, DataErrorCode };
|
|
20
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","names":[],"sources":["../src/errors.ts"],"mappings":";;AAUA;;;;AAAyB;AAWzB;;;KAXY,aAAA;;cAWC,SAAA,SAAkB,KAAA;EAII;EAAA,SAFxB,IAAA,EAAM,aAAA;cAEH,IAAA,EAAM,aAAA,EAAe,OAAA;AAAA"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/** A Continuum data error carrying a stable {@link DataErrorCode}. */
|
|
3
|
+
var DataError = class extends Error {
|
|
4
|
+
/** Stable, machine-readable cause. */
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "DataError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { DataError };
|
|
14
|
+
|
|
15
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Errors for `@mindees/data` (Continuum).\n *\n * Every failure carries a stable {@link DataErrorCode} so callers can branch on the\n * cause without string-matching messages.\n *\n * @module\n */\n\n/** Stable code identifying why a Continuum operation failed. */\nexport type DataErrorCode =\n /** `insert` was given an id that already exists (use `upsert` to replace). */\n | 'DUPLICATE_ID'\n /** `update` referenced an id that is not in the collection. */\n | 'RECORD_NOT_FOUND'\n /** A mutation tried to change a record's `id` (ids are immutable). */\n | 'ID_IMMUTABLE'\n /** `optimistic()` was called inside another `optimistic()` block (not reentrant). */\n | 'OPTIMISTIC_NESTED'\n\n/** A Continuum data error carrying a stable {@link DataErrorCode}. */\nexport class DataError extends Error {\n /** Stable, machine-readable cause. */\n readonly code: DataErrorCode\n\n constructor(code: DataErrorCode, message: string) {\n super(message)\n this.name = 'DataError'\n this.code = code\n }\n}\n"],"mappings":";;AAqBA,IAAa,YAAb,cAA+B,MAAM;;CAEnC;CAEA,YAAY,MAAqB,SAAiB;EAChD,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,OAAO;CACd;AACF"}
|
package/dist/hlc.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/hlc.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Hybrid Logical Clock (HLC) โ the causality primitive for Continuum.
|
|
4
|
+
*
|
|
5
|
+
* An {@link Hlc} pairs physical wall-clock milliseconds with a logical counter (and the
|
|
6
|
+
* replica's `nodeId`), so it tracks real time closely yet guarantees a **monotonic,
|
|
7
|
+
* total causal order** across replicas with no coordinator (Kulkarni et al.). The
|
|
8
|
+
* physical clock is **injected**, so behavior is fully deterministic in tests.
|
|
9
|
+
*
|
|
10
|
+
* 10C tags every field write with an `Hlc` (its per-field LWW merge key); 10D ships ops
|
|
11
|
+
* ordered by it. See `docs/adr/0013-continuum-hlc-causality.md`.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
/** A Hybrid Logical Clock timestamp. */
|
|
16
|
+
interface Hlc {
|
|
17
|
+
/** Physical wall-clock milliseconds. */
|
|
18
|
+
readonly wallMs: number;
|
|
19
|
+
/** Logical counter that breaks ties when `wallMs` does not advance. */
|
|
20
|
+
readonly counter: number;
|
|
21
|
+
/** The replica that produced this timestamp. */
|
|
22
|
+
readonly nodeId: string;
|
|
23
|
+
}
|
|
24
|
+
/** Options for {@link createClock}. */
|
|
25
|
+
interface ClockOptions {
|
|
26
|
+
/** This replica's stable id (must be unique per replica). */
|
|
27
|
+
readonly nodeId: string;
|
|
28
|
+
/** Injected physical clock in ms. Default `() => Date.now()`. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
/**
|
|
31
|
+
* Bound how far a received timestamp may push our **local** clock forward (a
|
|
32
|
+
* corrupt/skewed peer must not be able to drag our clock arbitrarily into the
|
|
33
|
+
* future). A remote beyond this bound is **clamped** when advancing our clock โ it
|
|
34
|
+
* is *not* rejected, so the op's own stamp is still merged by the data layer and
|
|
35
|
+
* convergence is preserved under clock skew. Default 24h.
|
|
36
|
+
*/
|
|
37
|
+
readonly maxClockDriftMs?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Seed the clock's high-water mark on creation (e.g. restoring a persisted replica),
|
|
40
|
+
* so the first {@link Clock.tick} after restart is strictly greater than any stamp
|
|
41
|
+
* the replica produced before it. Without this a restored clock regresses to 0 and a
|
|
42
|
+
* post-restart edit can lose the LWW merge to its own pre-restart write.
|
|
43
|
+
*/
|
|
44
|
+
readonly seed?: {
|
|
45
|
+
readonly wallMs: number;
|
|
46
|
+
readonly counter: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** A per-replica Hybrid Logical Clock. */
|
|
50
|
+
interface Clock {
|
|
51
|
+
/** Produce a timestamp for a new local event (or an outgoing message). */
|
|
52
|
+
tick(): Hlc;
|
|
53
|
+
/**
|
|
54
|
+
* Merge a received timestamp into the local clock; returns the new local timestamp
|
|
55
|
+
* (strictly greater than the previous local one, and greater than the remote unless
|
|
56
|
+
* the remote is beyond the drift bound, in which case its advance is clamped).
|
|
57
|
+
* Throws only on a structurally-invalid remote (non-integer/negative/non-encodable).
|
|
58
|
+
*/
|
|
59
|
+
update(remote: Hlc): Hlc;
|
|
60
|
+
/** The current local timestamp without advancing the clock. */
|
|
61
|
+
peek(): Hlc;
|
|
62
|
+
}
|
|
63
|
+
/** Create a {@link Clock} for `nodeId`. */
|
|
64
|
+
declare function createClock(options: ClockOptions): Clock;
|
|
65
|
+
/** Total order over {@link Hlc}: by `wallMs`, then `counter`, then `nodeId`. */
|
|
66
|
+
declare function compareHlc(a: Hlc, b: Hlc): -1 | 0 | 1;
|
|
67
|
+
/**
|
|
68
|
+
* Encode an {@link Hlc} as a **lexicographically-sortable** string
|
|
69
|
+
* (`wallMs:counter:nodeId`, zero-padded), so plain string sort matches
|
|
70
|
+
* {@link compareHlc}. Throws if a field exceeds its fixed width (which would silently
|
|
71
|
+
* break the sort order) โ the clock keeps both within range, so this only fires on a
|
|
72
|
+
* hand-built/decoded out-of-range value. Inverse of {@link decodeHlc}.
|
|
73
|
+
*/
|
|
74
|
+
declare function encodeHlc(hlc: Hlc): string;
|
|
75
|
+
/** Decode a string produced by {@link encodeHlc} (a `nodeId` may itself contain `:`). */
|
|
76
|
+
declare function decodeHlc(encoded: string): Hlc;
|
|
77
|
+
//#endregion
|
|
78
|
+
export { Clock, ClockOptions, Hlc, compareHlc, createClock, decodeHlc, encodeHlc };
|
|
79
|
+
//# sourceMappingURL=hlc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.d.ts","names":[],"sources":["../src/hlc.ts"],"mappings":";;AAeA;;;;;;;;AAMiB;AAIjB;;;;UAViB,GAAA;EAcN;EAAA,SAZA,MAAA;EA2BA;EAAA,SAzBA,OAAA;EAyB2C;EAAA,SAvB3C,MAAA;AAAA;AAkCX;AAAA,UA9BiB,YAAA;;WAEN,MAAA;EAqCM;EAAA,SAnCN,GAAA;EAqCD;;;;;;;EAAA,SA7BC,eAAA;EA2BY;;;;AAEV;AAIb;EANuB,SApBZ,IAAA;IAAA,SAAkB,MAAA;IAAA,SAAyB,OAAA;EAAA;AAAA;;UAWrC,KAAA;EAewC;EAbvD,IAAA,IAAQ,GAAA;EA+FgB;;;;;;EAxFxB,MAAA,CAAO,MAAA,EAAQ,GAAA,GAAM,GAAA;EAwFkB;EAtFvC,IAAA,IAAQ,GAAA;AAAA;;iBAIM,WAAA,CAAY,OAAA,EAAS,YAAA,GAAe,KAAK;;iBAkFzC,UAAA,CAAW,CAAA,EAAG,GAAA,EAAK,CAAA,EAAG,GAAG;AA6BzC;;;;AAA+C;;;AAA/C,iBAfgB,SAAA,CAAU,GAAQ,EAAH,GAAG;;iBAelB,SAAA,CAAU,OAAA,WAAkB,GAAG"}
|
package/dist/hlc.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//#region src/hlc.ts
|
|
2
|
+
/** Max counter value that fits the encoded fixed width; overflow rolls into `wallMs`. */
|
|
3
|
+
const MAX_COUNTER = 999999;
|
|
4
|
+
const WALL_WIDTH = 15;
|
|
5
|
+
const COUNTER_WIDTH = 6;
|
|
6
|
+
/** Largest `wallMs` the fixed-width encoding can represent (~year 33658). */
|
|
7
|
+
const MAX_WALL = 10 ** WALL_WIDTH - 1;
|
|
8
|
+
/** Create a {@link Clock} for `nodeId`. */
|
|
9
|
+
function createClock(options) {
|
|
10
|
+
const { nodeId } = options;
|
|
11
|
+
const physical = options.now ?? (() => Date.now());
|
|
12
|
+
const maxDriftMs = options.maxClockDriftMs ?? 1440 * 60 * 1e3;
|
|
13
|
+
const seedWall = options.seed?.wallMs;
|
|
14
|
+
const seedCounter = options.seed?.counter;
|
|
15
|
+
let wallMs = typeof seedWall === "number" && Number.isInteger(seedWall) && seedWall >= 0 && seedWall <= MAX_WALL ? seedWall : 0;
|
|
16
|
+
let counter = typeof seedCounter === "number" && Number.isInteger(seedCounter) && seedCounter >= 0 && seedCounter <= MAX_COUNTER ? seedCounter : 0;
|
|
17
|
+
const commit = (w, c) => {
|
|
18
|
+
if (c > MAX_COUNTER) {
|
|
19
|
+
wallMs = w + 1;
|
|
20
|
+
counter = 0;
|
|
21
|
+
} else {
|
|
22
|
+
wallMs = w;
|
|
23
|
+
counter = c;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
wallMs,
|
|
27
|
+
counter,
|
|
28
|
+
nodeId
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
tick() {
|
|
33
|
+
const pt = physical();
|
|
34
|
+
return pt > wallMs ? commit(pt, 0) : commit(wallMs, counter + 1);
|
|
35
|
+
},
|
|
36
|
+
update(remote) {
|
|
37
|
+
const pt = physical();
|
|
38
|
+
if (!Number.isInteger(remote.wallMs) || remote.wallMs < 0 || remote.wallMs > MAX_WALL || !Number.isInteger(remote.counter) || remote.counter < 0 || remote.counter > MAX_COUNTER) throw new TypeError(`remote HLC out of bounds: ${remote.wallMs}:${remote.counter}`);
|
|
39
|
+
const ceiling = Math.max(wallMs, pt + maxDriftMs);
|
|
40
|
+
const remoteWall = Math.min(remote.wallMs, ceiling);
|
|
41
|
+
const w = Math.max(wallMs, remoteWall, pt);
|
|
42
|
+
let c;
|
|
43
|
+
if (w === wallMs && w === remoteWall) c = Math.max(counter, remote.counter) + 1;
|
|
44
|
+
else if (w === wallMs) c = counter + 1;
|
|
45
|
+
else if (w === remoteWall) c = remote.counter + 1;
|
|
46
|
+
else c = 0;
|
|
47
|
+
return commit(w, c);
|
|
48
|
+
},
|
|
49
|
+
peek() {
|
|
50
|
+
return {
|
|
51
|
+
wallMs,
|
|
52
|
+
counter,
|
|
53
|
+
nodeId
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Total order over {@link Hlc}: by `wallMs`, then `counter`, then `nodeId`. */
|
|
59
|
+
function compareHlc(a, b) {
|
|
60
|
+
if (a.wallMs !== b.wallMs) return a.wallMs < b.wallMs ? -1 : 1;
|
|
61
|
+
if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1;
|
|
62
|
+
if (a.nodeId !== b.nodeId) return a.nodeId < b.nodeId ? -1 : 1;
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Encode an {@link Hlc} as a **lexicographically-sortable** string
|
|
67
|
+
* (`wallMs:counter:nodeId`, zero-padded), so plain string sort matches
|
|
68
|
+
* {@link compareHlc}. Throws if a field exceeds its fixed width (which would silently
|
|
69
|
+
* break the sort order) โ the clock keeps both within range, so this only fires on a
|
|
70
|
+
* hand-built/decoded out-of-range value. Inverse of {@link decodeHlc}.
|
|
71
|
+
*/
|
|
72
|
+
function encodeHlc(hlc) {
|
|
73
|
+
if (!Number.isInteger(hlc.wallMs) || hlc.wallMs < 0 || hlc.wallMs > MAX_WALL) throw new RangeError(`HLC wallMs out of encodable range: ${hlc.wallMs}`);
|
|
74
|
+
if (!Number.isInteger(hlc.counter) || hlc.counter < 0 || hlc.counter > MAX_COUNTER) throw new RangeError(`HLC counter out of encodable range: ${hlc.counter}`);
|
|
75
|
+
return `${String(hlc.wallMs).padStart(WALL_WIDTH, "0")}:${String(hlc.counter).padStart(COUNTER_WIDTH, "0")}:${hlc.nodeId}`;
|
|
76
|
+
}
|
|
77
|
+
const DIGITS = /^\d+$/;
|
|
78
|
+
/** Decode a string produced by {@link encodeHlc} (a `nodeId` may itself contain `:`). */
|
|
79
|
+
function decodeHlc(encoded) {
|
|
80
|
+
const first = encoded.indexOf(":");
|
|
81
|
+
const second = encoded.indexOf(":", first + 1);
|
|
82
|
+
if (first === -1 || second === -1) throw new TypeError(`invalid encoded HLC: ${encoded}`);
|
|
83
|
+
const wall = encoded.slice(0, first);
|
|
84
|
+
const counter = encoded.slice(first + 1, second);
|
|
85
|
+
if (!DIGITS.test(wall) || !DIGITS.test(counter)) throw new TypeError(`invalid encoded HLC numeric field: ${encoded}`);
|
|
86
|
+
return {
|
|
87
|
+
wallMs: Number(wall),
|
|
88
|
+
counter: Number(counter),
|
|
89
|
+
nodeId: encoded.slice(second + 1)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { compareHlc, createClock, decodeHlc, encodeHlc };
|
|
94
|
+
|
|
95
|
+
//# sourceMappingURL=hlc.js.map
|
package/dist/hlc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hlc.js","names":[],"sources":["../src/hlc.ts"],"sourcesContent":["/**\n * Hybrid Logical Clock (HLC) โ the causality primitive for Continuum.\n *\n * An {@link Hlc} pairs physical wall-clock milliseconds with a logical counter (and the\n * replica's `nodeId`), so it tracks real time closely yet guarantees a **monotonic,\n * total causal order** across replicas with no coordinator (Kulkarni et al.). The\n * physical clock is **injected**, so behavior is fully deterministic in tests.\n *\n * 10C tags every field write with an `Hlc` (its per-field LWW merge key); 10D ships ops\n * ordered by it. See `docs/adr/0013-continuum-hlc-causality.md`.\n *\n * @module\n */\n\n/** A Hybrid Logical Clock timestamp. */\nexport interface Hlc {\n /** Physical wall-clock milliseconds. */\n readonly wallMs: number\n /** Logical counter that breaks ties when `wallMs` does not advance. */\n readonly counter: number\n /** The replica that produced this timestamp. */\n readonly nodeId: string\n}\n\n/** Options for {@link createClock}. */\nexport interface ClockOptions {\n /** This replica's stable id (must be unique per replica). */\n readonly nodeId: string\n /** Injected physical clock in ms. Default `() => Date.now()`. */\n readonly now?: () => number\n /**\n * Bound how far a received timestamp may push our **local** clock forward (a\n * corrupt/skewed peer must not be able to drag our clock arbitrarily into the\n * future). A remote beyond this bound is **clamped** when advancing our clock โ it\n * is *not* rejected, so the op's own stamp is still merged by the data layer and\n * convergence is preserved under clock skew. Default 24h.\n */\n readonly maxClockDriftMs?: number\n /**\n * Seed the clock's high-water mark on creation (e.g. restoring a persisted replica),\n * so the first {@link Clock.tick} after restart is strictly greater than any stamp\n * the replica produced before it. Without this a restored clock regresses to 0 and a\n * post-restart edit can lose the LWW merge to its own pre-restart write.\n */\n readonly seed?: { readonly wallMs: number; readonly counter: number }\n}\n\n/** Max counter value that fits the encoded fixed width; overflow rolls into `wallMs`. */\nconst MAX_COUNTER = 999_999\nconst WALL_WIDTH = 15\nconst COUNTER_WIDTH = 6\n/** Largest `wallMs` the fixed-width encoding can represent (~year 33658). */\nconst MAX_WALL = 10 ** WALL_WIDTH - 1\n\n/** A per-replica Hybrid Logical Clock. */\nexport interface Clock {\n /** Produce a timestamp for a new local event (or an outgoing message). */\n tick(): Hlc\n /**\n * Merge a received timestamp into the local clock; returns the new local timestamp\n * (strictly greater than the previous local one, and greater than the remote unless\n * the remote is beyond the drift bound, in which case its advance is clamped).\n * Throws only on a structurally-invalid remote (non-integer/negative/non-encodable).\n */\n update(remote: Hlc): Hlc\n /** The current local timestamp without advancing the clock. */\n peek(): Hlc\n}\n\n/** Create a {@link Clock} for `nodeId`. */\nexport function createClock(options: ClockOptions): Clock {\n const { nodeId } = options\n const physical = options.now ?? (() => Date.now())\n const maxDriftMs = options.maxClockDriftMs ?? 24 * 60 * 60 * 1000\n // Seed the high-water mark (sanitized โ a corrupt snapshot must not break the clock).\n const seedWall = options.seed?.wallMs\n const seedCounter = options.seed?.counter\n let wallMs =\n typeof seedWall === 'number' &&\n Number.isInteger(seedWall) &&\n seedWall >= 0 &&\n seedWall <= MAX_WALL\n ? seedWall\n : 0\n let counter =\n typeof seedCounter === 'number' &&\n Number.isInteger(seedCounter) &&\n seedCounter >= 0 &&\n seedCounter <= MAX_COUNTER\n ? seedCounter\n : 0\n\n // Commit (wall, candidateCounter), rolling a logical-counter overflow into wall time\n // so the counter stays within its fixed encoding width while monotonicity holds.\n const commit = (w: number, c: number): Hlc => {\n if (c > MAX_COUNTER) {\n wallMs = w + 1\n counter = 0\n } else {\n wallMs = w\n counter = c\n }\n return { wallMs, counter, nodeId }\n }\n\n return {\n tick(): Hlc {\n const pt = physical()\n return pt > wallMs ? commit(pt, 0) : commit(wallMs, counter + 1)\n },\n\n update(remote: Hlc): Hlc {\n const pt = physical()\n // Reject only a STRUCTURALLY-invalid remote (non-integer/negative, or beyond the\n // fixed encodable range) โ such a stamp can never be ordered or stored, so the\n // caller skips that op. A merely far-future *but representable* remote is NOT\n // rejected here: the data merge (mergeRegister) is independent of our clock, so\n // dropping it would break CRDT convergence. We instead CLAMP how far it advances\n // our local clock (below) to keep the clock from being poisoned.\n if (\n !Number.isInteger(remote.wallMs) ||\n remote.wallMs < 0 ||\n remote.wallMs > MAX_WALL ||\n !Number.isInteger(remote.counter) ||\n remote.counter < 0 ||\n remote.counter > MAX_COUNTER\n ) {\n throw new TypeError(`remote HLC out of bounds: ${remote.wallMs}:${remote.counter}`)\n }\n // Clock-poisoning guard: never let a remote push our local clock more than\n // maxDriftMs beyond PHYSICAL time. Anchoring to `pt + maxDriftMs` (not\n // `wallMs + maxDriftMs`) means repeated far-future merges can't ratchet the clock\n // forward โ each is clamped to the same ceiling. The `max(wallMs, โฆ)` floor keeps a\n // stamp at/below our current clock always adopted (it advances nothing).\n const ceiling = Math.max(wallMs, pt + maxDriftMs)\n const remoteWall = Math.min(remote.wallMs, ceiling)\n const w = Math.max(wallMs, remoteWall, pt)\n let c: number\n if (w === wallMs && w === remoteWall) c = Math.max(counter, remote.counter) + 1\n else if (w === wallMs) c = counter + 1\n else if (w === remoteWall) c = remote.counter + 1\n else c = 0\n return commit(w, c)\n },\n\n peek(): Hlc {\n return { wallMs, counter, nodeId }\n },\n }\n}\n\n/** Total order over {@link Hlc}: by `wallMs`, then `counter`, then `nodeId`. */\nexport function compareHlc(a: Hlc, b: Hlc): -1 | 0 | 1 {\n if (a.wallMs !== b.wallMs) return a.wallMs < b.wallMs ? -1 : 1\n if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1\n if (a.nodeId !== b.nodeId) return a.nodeId < b.nodeId ? -1 : 1\n return 0\n}\n\n/**\n * Encode an {@link Hlc} as a **lexicographically-sortable** string\n * (`wallMs:counter:nodeId`, zero-padded), so plain string sort matches\n * {@link compareHlc}. Throws if a field exceeds its fixed width (which would silently\n * break the sort order) โ the clock keeps both within range, so this only fires on a\n * hand-built/decoded out-of-range value. Inverse of {@link decodeHlc}.\n */\nexport function encodeHlc(hlc: Hlc): string {\n if (!Number.isInteger(hlc.wallMs) || hlc.wallMs < 0 || hlc.wallMs > MAX_WALL) {\n throw new RangeError(`HLC wallMs out of encodable range: ${hlc.wallMs}`)\n }\n if (!Number.isInteger(hlc.counter) || hlc.counter < 0 || hlc.counter > MAX_COUNTER) {\n throw new RangeError(`HLC counter out of encodable range: ${hlc.counter}`)\n }\n const wall = String(hlc.wallMs).padStart(WALL_WIDTH, '0')\n const counter = String(hlc.counter).padStart(COUNTER_WIDTH, '0')\n return `${wall}:${counter}:${hlc.nodeId}`\n}\n\nconst DIGITS = /^\\d+$/\n\n/** Decode a string produced by {@link encodeHlc} (a `nodeId` may itself contain `:`). */\nexport function decodeHlc(encoded: string): Hlc {\n const first = encoded.indexOf(':')\n const second = encoded.indexOf(':', first + 1)\n if (first === -1 || second === -1) {\n throw new TypeError(`invalid encoded HLC: ${encoded}`)\n }\n const wall = encoded.slice(0, first)\n const counter = encoded.slice(first + 1, second)\n if (!DIGITS.test(wall) || !DIGITS.test(counter)) {\n throw new TypeError(`invalid encoded HLC numeric field: ${encoded}`)\n }\n return { wallMs: Number(wall), counter: Number(counter), nodeId: encoded.slice(second + 1) }\n}\n"],"mappings":"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"}
|