@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 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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"}
@@ -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
@@ -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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"}