@tangleai/context 0.21.1

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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The in-memory storage adapter: the ledger's default, and the reason
3
+ * `createLedger()` works with no arguments at all.
4
+ *
5
+ * A host that wants durability injects its own adapter over
6
+ * `@jarenjs/db` (OPFS, survives the tab), `localStorage`, a file, or a
7
+ * server. `@tangleai/context` gains no dependency either way — the whole
8
+ * posture of this package is that it loads in a static page with two
9
+ * dependencies, and a store is the largest thing it could have been made
10
+ * to import.
11
+ *
12
+ * The base adapter contract:
13
+ *
14
+ * get(key) -> Promise<any | undefined> a JSON value, or undefined
15
+ * set(key, val) -> Promise<void> val is a JSON value
16
+ * delete(key) -> Promise<void> absent key is not an error
17
+ * keys(prefix) -> Promise<string[]> every key starting with prefix, sorted
18
+ *
19
+ * Four methods, all async, all JSON. `keys()` answers in lexicographic
20
+ * order, and the ledger depends on it: listings, the goal archive and a
21
+ * snapshot's entries are read in key order, and its zero-padded
22
+ * sequences exist so that order is chronological. Async even here,
23
+ * where nothing needs to be: an adapter over IndexedDB or OPFS is
24
+ * unavoidably async, and a synchronous default would let a caller write
25
+ * code that silently breaks the moment real storage is wired in.
26
+ *
27
+ * Optional `mutate(scope, fn)` reads and replaces a detached record map in one
28
+ * indivisible step. Scopes select prefixes and/or exact keys; omitting the next
29
+ * map is read-only. The ledger uses it for staged, atomic publication.
30
+ *
31
+ * Another optional capability, for an adapter that can rank vectors where
32
+ * they live instead of handing every record over:
33
+ *
34
+ * rank({ prefix, vector, model, dims, limit, minScore })
35
+ * -> Promise<{ hits: { key, score }[], skipped, identities,
36
+ * ranking?: { algorithm, exhaustive, candidateCount } }>
37
+ *
38
+ * `hits` are the best `limit` records under `prefix` whose
39
+ * `embeddedBy` is `{ model, dims }`, best first; `skipped` is how many
40
+ * records under the prefix carry no embedding at all; `identities` is
41
+ * every DISTINCT `embeddedBy` the prefix holds, which is what lets the
42
+ * ledger refuse a mixture in its own words rather than each adapter
43
+ * inventing them. The ledger re-scores what comes back with its own
44
+ * kernels and applies `minScore` and `limit` itself, so `score` selects
45
+ * candidates and never decides the answer; an adapter free to rank
46
+ * approximately identifies its algorithm with `exhaustive: false`. Legacy
47
+ * adapters without metadata normalize to `legacy-exact`, exhaustive. The
48
+ * candidate count equals the returned hit count before ledger filtering.
49
+ * Returned keys must be unique and under the requested prefix; every fetched
50
+ * record is checked for identity and valid vector shape before re-scoring.
51
+ * The identity report must still cover omitted records: the ledger cannot
52
+ * independently prove an adapter's completeness without doing its own scan.
53
+ *
54
+ * This is the `compileQuery` seam's shape, one layer down: a capability
55
+ * that is present or absent, never half-implemented. An adapter without
56
+ * it loses nothing — `recall({ near })` reads and ranks, and reports
57
+ * `via: 'sweep'` — which is why the in-memory adapter below does not
58
+ * grow it.
59
+ */
60
+ /**
61
+ * Create an in-memory adapter with JSON-value semantics: every value is
62
+ * serialized on the way in and parsed on the way out, so what survives
63
+ * is exactly what `JSON.stringify` preserves — a typed array comes back
64
+ * as a plain object of its indices, `NaN` and `Infinity` as `null`, and
65
+ * `undefined` members vanish. That is the contract, not a shortcut: real
66
+ * storage serializes, and a default that kept more (a structured clone
67
+ * would keep a `Float32Array`) would let the test path behave
68
+ * differently from the durable one, which is the kind of difference
69
+ * that surfaces in production and nowhere else. The round-trip also
70
+ * copies: a caller that mutates what it stored — or what it read —
71
+ * cannot reach inside the ledger.
72
+ *
73
+ * @param {Map<string, string>} [backing] - an existing map to adopt
74
+ * @returns {{ get: (key: string) => Promise<any>,
75
+ * set: (key: string, value: any) => Promise<void>,
76
+ * delete: (key: string) => Promise<void>,
77
+ * keys: (prefix?: string) => Promise<string[]>,
78
+ * mutate: import('./transaction.js').StorageMutation }}
79
+ */
80
+ export function createMemoryStorage(backing?: Map<string, string>): {
81
+ get: (key: string) => Promise<any>;
82
+ set: (key: string, value: any) => Promise<void>;
83
+ delete: (key: string) => Promise<void>;
84
+ keys: (prefix?: string) => Promise<string[]>;
85
+ mutate: import("./transaction.js").StorageMutation;
86
+ };
@@ -0,0 +1,118 @@
1
+ //@ts-check
2
+ /**
3
+ * The in-memory storage adapter: the ledger's default, and the reason
4
+ * `createLedger()` works with no arguments at all.
5
+ *
6
+ * A host that wants durability injects its own adapter over
7
+ * `@jarenjs/db` (OPFS, survives the tab), `localStorage`, a file, or a
8
+ * server. `@tangleai/context` gains no dependency either way — the whole
9
+ * posture of this package is that it loads in a static page with two
10
+ * dependencies, and a store is the largest thing it could have been made
11
+ * to import.
12
+ *
13
+ * The base adapter contract:
14
+ *
15
+ * get(key) -> Promise<any | undefined> a JSON value, or undefined
16
+ * set(key, val) -> Promise<void> val is a JSON value
17
+ * delete(key) -> Promise<void> absent key is not an error
18
+ * keys(prefix) -> Promise<string[]> every key starting with prefix, sorted
19
+ *
20
+ * Four methods, all async, all JSON. `keys()` answers in lexicographic
21
+ * order, and the ledger depends on it: listings, the goal archive and a
22
+ * snapshot's entries are read in key order, and its zero-padded
23
+ * sequences exist so that order is chronological. Async even here,
24
+ * where nothing needs to be: an adapter over IndexedDB or OPFS is
25
+ * unavoidably async, and a synchronous default would let a caller write
26
+ * code that silently breaks the moment real storage is wired in.
27
+ *
28
+ * Optional `mutate(scope, fn)` reads and replaces a detached record map in one
29
+ * indivisible step. Scopes select prefixes and/or exact keys; omitting the next
30
+ * map is read-only. The ledger uses it for staged, atomic publication.
31
+ *
32
+ * Another optional capability, for an adapter that can rank vectors where
33
+ * they live instead of handing every record over:
34
+ *
35
+ * rank({ prefix, vector, model, dims, limit, minScore })
36
+ * -> Promise<{ hits: { key, score }[], skipped, identities,
37
+ * ranking?: { algorithm, exhaustive, candidateCount } }>
38
+ *
39
+ * `hits` are the best `limit` records under `prefix` whose
40
+ * `embeddedBy` is `{ model, dims }`, best first; `skipped` is how many
41
+ * records under the prefix carry no embedding at all; `identities` is
42
+ * every DISTINCT `embeddedBy` the prefix holds, which is what lets the
43
+ * ledger refuse a mixture in its own words rather than each adapter
44
+ * inventing them. The ledger re-scores what comes back with its own
45
+ * kernels and applies `minScore` and `limit` itself, so `score` selects
46
+ * candidates and never decides the answer; an adapter free to rank
47
+ * approximately identifies its algorithm with `exhaustive: false`. Legacy
48
+ * adapters without metadata normalize to `legacy-exact`, exhaustive. The
49
+ * candidate count equals the returned hit count before ledger filtering.
50
+ * Returned keys must be unique and under the requested prefix; every fetched
51
+ * record is checked for identity and valid vector shape before re-scoring.
52
+ * The identity report must still cover omitted records: the ledger cannot
53
+ * independently prove an adapter's completeness without doing its own scan.
54
+ *
55
+ * This is the `compileQuery` seam's shape, one layer down: a capability
56
+ * that is present or absent, never half-implemented. An adapter without
57
+ * it loses nothing — `recall({ near })` reads and ranks, and reports
58
+ * `via: 'sweep'` — which is why the in-memory adapter below does not
59
+ * grow it.
60
+ */
61
+
62
+ /**
63
+ * Create an in-memory adapter with JSON-value semantics: every value is
64
+ * serialized on the way in and parsed on the way out, so what survives
65
+ * is exactly what `JSON.stringify` preserves — a typed array comes back
66
+ * as a plain object of its indices, `NaN` and `Infinity` as `null`, and
67
+ * `undefined` members vanish. That is the contract, not a shortcut: real
68
+ * storage serializes, and a default that kept more (a structured clone
69
+ * would keep a `Float32Array`) would let the test path behave
70
+ * differently from the durable one, which is the kind of difference
71
+ * that surfaces in production and nowhere else. The round-trip also
72
+ * copies: a caller that mutates what it stored — or what it read —
73
+ * cannot reach inside the ledger.
74
+ *
75
+ * @param {Map<string, string>} [backing] - an existing map to adopt
76
+ * @returns {{ get: (key: string) => Promise<any>,
77
+ * set: (key: string, value: any) => Promise<void>,
78
+ * delete: (key: string) => Promise<void>,
79
+ * keys: (prefix?: string) => Promise<string[]>,
80
+ * mutate: import('./transaction.js').StorageMutation }}
81
+ */
82
+ export function createMemoryStorage(backing = new Map()) {
83
+ return {
84
+ mutate: async (prefix, transform) => {
85
+ const matches = (key) => typeof prefix === 'string' ? key.startsWith(prefix)
86
+ : (prefix.keys ?? []).includes(key) || (prefix.prefixes ?? []).some((part) => key.startsWith(part));
87
+ // No await between read and publication: even separate adapters sharing
88
+ // this map observe one complete mutation. Serialize every value first.
89
+ const current = Object.fromEntries([...backing].filter(([key]) => matches(key))
90
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
91
+ .map(([key, raw]) => [key, JSON.parse(raw)]));
92
+ const outcome = transform(current);
93
+ if (!outcome || typeof outcome.then === 'function') throw new TypeError('mutate callback must be synchronous');
94
+ if (outcome.next !== undefined) {
95
+ const entries = Object.entries(outcome.next).map(([key, value]) => {
96
+ if (!matches(key)) throw new TypeError('mutation escaped its namespace');
97
+ return [key, JSON.stringify(value)];
98
+ });
99
+ for (const key of backing.keys()) if (matches(key)) backing.delete(key);
100
+ for (const [key, raw] of entries) backing.set(key, raw);
101
+ }
102
+ return outcome.result;
103
+ },
104
+ get: async (key) => {
105
+ const raw = backing.get(key);
106
+ return raw === undefined ? undefined : JSON.parse(raw);
107
+ },
108
+ set: async (key, value) => {
109
+ backing.set(key, JSON.stringify(value));
110
+ },
111
+ delete: async (key) => {
112
+ backing.delete(key);
113
+ },
114
+ keys: async (prefix = '') => [...backing.keys()]
115
+ .filter((key) => key.startsWith(prefix))
116
+ .sort(),
117
+ };
118
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * JSON slots must report failed writes by throwing or returning false. A legacy
3
+ * slot that swallows errors cannot promise durability; status says unverified.
4
+ * Web Locks serialize tabs using the same slot name. Without them the adapter
5
+ * explicitly exposes only the four-method, single-writer contract.
6
+ * @param {{ read: () => any, write: (data: any) => any, key?: string, reliable?: boolean }} slot
7
+ * @param {{ locks?: any, name?: string, singleWriter?: boolean }} [options]
8
+ */
9
+ export function createSlotLedgerStorage(slot: {
10
+ read: () => any;
11
+ write: (data: any) => any;
12
+ key?: string;
13
+ reliable?: boolean;
14
+ }, options?: {
15
+ locks?: any;
16
+ name?: string;
17
+ singleWriter?: boolean;
18
+ }): {
19
+ status: () => {
20
+ concurrency: string;
21
+ durability: string;
22
+ error: any;
23
+ };
24
+ close: () => Promise<void>;
25
+ get: (key: any) => Promise<any>;
26
+ set: (key: any, value: any) => Promise<any>;
27
+ delete: (key: any) => Promise<any>;
28
+ keys: (prefix?: string) => Promise<string[]>;
29
+ mutate?: (prefix: any, transform: any) => Promise<any>;
30
+ };
@@ -0,0 +1,107 @@
1
+ //@ts-check
2
+ /** A whole-slot ledger adapter. Reload under the shared Web Lock before writes. */
3
+ const copy = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
4
+
5
+ /**
6
+ * JSON slots must report failed writes by throwing or returning false. A legacy
7
+ * slot that swallows errors cannot promise durability; status says unverified.
8
+ * Web Locks serialize tabs using the same slot name. Without them the adapter
9
+ * explicitly exposes only the four-method, single-writer contract.
10
+ * @param {{ read: () => any, write: (data: any) => any, key?: string, reliable?: boolean }} slot
11
+ * @param {{ locks?: any, name?: string, singleWriter?: boolean }} [options]
12
+ */
13
+ export function createSlotLedgerStorage(slot, options = {}) {
14
+ const locks = options.locks;
15
+ const name = options.name ?? slot.key ?? 'jaren-ai-ledger';
16
+ let closed = false;
17
+ const active = () => { if (closed) throw new Error('The ledger storage adapter is closed.'); };
18
+ let cache = {};
19
+ let error = null;
20
+ let durable = slot.reliable === true ? 'durable' : 'unverified';
21
+ const load = () => {
22
+ active();
23
+ const raw = slot.read();
24
+ if (raw !== null && raw !== undefined && (typeof raw !== 'object' || Array.isArray(raw)))
25
+ throw new TypeError('invalid ledger slot: expected a record map');
26
+ cache = copy(raw ?? {});
27
+ return cache;
28
+ };
29
+ const publish = (next) => {
30
+ active();
31
+ try {
32
+ if (slot.write(next) === false) throw new Error('ledger storage write failed');
33
+ cache = copy(next);
34
+ error = null;
35
+ durable = slot.reliable === true ? 'durable' : 'unverified';
36
+ }
37
+ catch (cause) {
38
+ error = cause instanceof Error ? cause.message : String(cause);
39
+ durable = 'failed';
40
+ throw cause;
41
+ }
42
+ };
43
+ const atomic = typeof locks?.request === 'function';
44
+ // WebKit's process-local storage snapshots can remain stale even across a
45
+ // locked task boundary. Elect one owner there instead of claiming coherence.
46
+ const singleWriter = options.singleWriter ?? false;
47
+ let ownership;
48
+ let releaseOwner;
49
+ let ownerRequest;
50
+ const own = () => {
51
+ active();
52
+ if (!atomic || !singleWriter) return Promise.resolve();
53
+ ownership ??= new Promise((resolve, reject) => {
54
+ const held = new Promise((release) => { releaseOwner = release; });
55
+ ownerRequest = locks.request(`ledger-owner:${name}`, { ifAvailable: true }, (lock) => {
56
+ if (!lock) {
57
+ reject(new Error('single-writer ledger: another tab owns this storage; close that tab before writing here'));
58
+ return;
59
+ }
60
+ resolve();
61
+ return held;
62
+ });
63
+ ownerRequest.catch(reject);
64
+ });
65
+ ownership.catch(() => { ownership = undefined; });
66
+ return ownership;
67
+ };
68
+ // Firefox publishes localStorage snapshots at task boundaries. Keep the
69
+ // lock until that publication completes, and enter a fresh task before read.
70
+ const taskBoundary = () => new Promise((resolve) => setTimeout(resolve, 0));
71
+ const locked = (fn) => atomic ? locks.request(`ledger:${name}`, async () => {
72
+ await taskBoundary();
73
+ try { return fn(); }
74
+ finally { await taskBoundary(); }
75
+ }) : Promise.resolve().then(fn);
76
+ const mutate = async (prefix, transform) => {
77
+ await own();
78
+ return locked(() => {
79
+ const matches = (key) => typeof prefix === 'string' ? key.startsWith(prefix)
80
+ : (prefix.keys ?? []).includes(key) || (prefix.prefixes ?? []).some((part) => key.startsWith(part));
81
+ const current = load();
82
+ const scoped = Object.fromEntries(Object.keys(current).sort()
83
+ .filter((key) => matches(key)).map((key) => [key, copy(current[key])]));
84
+ const outcome = transform(scoped);
85
+ if (!outcome || typeof outcome.then === 'function') throw new TypeError('mutate callback must be synchronous');
86
+ if (outcome.next !== undefined) {
87
+ const next = copy(outcome.next);
88
+ if (Object.keys(next).some((key) => !matches(key))) throw new TypeError('mutation escaped its namespace');
89
+ for (const key of Object.keys(current)) if (matches(key)) delete current[key];
90
+ publish({ ...current, ...next });
91
+ }
92
+ return outcome.result;
93
+ });
94
+ };
95
+ return {
96
+ ...(atomic ? { mutate } : {}),
97
+ status: () => ({ concurrency: atomic && !singleWriter ? 'atomic' : 'single-writer', durability: durable, error }),
98
+ close: async () => { closed = true; releaseOwner?.(); await ownerRequest; ownership = undefined; },
99
+ get: async (key) => { await own(); return copy(load()[key]); },
100
+ set: (key, value) => mutate('', (current) => ({ next: { ...current, [key]: copy(value) } })),
101
+ delete: (key) => mutate('', (current) => {
102
+ delete current[key];
103
+ return { next: current };
104
+ }),
105
+ keys: async (prefix = '') => { await own(); return Object.keys(load()).filter((key) => key.startsWith(prefix)).sort(); },
106
+ };
107
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Stage async ledger work off-store, then publish through one synchronous CAS.
3
+ * Readers never observe the staged adapter. Competing commits retry against a
4
+ * fresh record map; external I/O belongs outside the transform callback.
5
+ * Four-method adapters retain their explicit single-writer behavior.
6
+ * @template T
7
+ * @param {any} storage
8
+ * @param {StorageScope} prefix
9
+ * @param {(view: any) => Promise<T>} task
10
+ * @returns {Promise<T>}
11
+ */
12
+ export function atomicTask<T>(storage: any, prefix: StorageScope, task: (view: any) => Promise<T>): Promise<T>;
13
+ export function isAtomicView(view: any): boolean;
14
+ /**
15
+ * Atomically transform the JSON record map under a namespace. The synchronous
16
+ * callback receives a detached current map and returns {next, result}; omitting
17
+ * next is a read-only decision. Failure publishes nothing. Keys cannot escape
18
+ * the namespace. Adapters serialize the callback with every other write.
19
+ */
20
+ export type StorageScope = string | {
21
+ prefixes?: string[];
22
+ keys?: string[];
23
+ };
24
+ /**
25
+ * Atomically transform the JSON record map under a namespace. The synchronous
26
+ * callback receives a detached current map and returns {next, result}; omitting
27
+ * next is a read-only decision. Failure publishes nothing. Keys cannot escape
28
+ * the namespace. Adapters serialize the callback with every other write.
29
+ */
30
+ export type StorageMutation = (prefix: StorageScope, transform: (current: Record<string, any>) => {
31
+ next?: Record<string, any>;
32
+ result?: any;
33
+ }) => Promise<any>;
@@ -0,0 +1,49 @@
1
+ //@ts-check
2
+ import { createMemoryStorage } from './memory.js';
3
+
4
+ /**
5
+ * Atomically transform the JSON record map under a namespace. The synchronous
6
+ * callback receives a detached current map and returns {next, result}; omitting
7
+ * next is a read-only decision. Failure publishes nothing. Keys cannot escape
8
+ * the namespace. Adapters serialize the callback with every other write.
9
+ * @typedef {string | { prefixes?: string[], keys?: string[] }} StorageScope
10
+ * @typedef {(prefix: StorageScope, transform: (current: Record<string, any>) =>
11
+ * { next?: Record<string, any>, result?: any }) => Promise<any>} StorageMutation
12
+ */
13
+
14
+ const staged = new WeakSet();
15
+ /** Whether a private view is already enclosed by an atomic publication. */
16
+ export const isAtomicView = (view) => staged.has(view);
17
+
18
+ const signature = (map) => JSON.stringify(Object.keys(map).sort().map((key) => [key, map[key]]));
19
+
20
+ /**
21
+ * Stage async ledger work off-store, then publish through one synchronous CAS.
22
+ * Readers never observe the staged adapter. Competing commits retry against a
23
+ * fresh record map; external I/O belongs outside the transform callback.
24
+ * Four-method adapters retain their explicit single-writer behavior.
25
+ * @template T
26
+ * @param {any} storage
27
+ * @param {StorageScope} prefix
28
+ * @param {(view: any) => Promise<T>} task
29
+ * @returns {Promise<T>}
30
+ */
31
+ export async function atomicTask(storage, prefix, task) {
32
+ if (typeof storage.mutate !== 'function') return task(storage);
33
+ for (let attempt = 0; attempt < 128; attempt++) {
34
+ const before = await storage.mutate(prefix, (current) => ({ result: current }));
35
+ const expected = signature(before);
36
+ const backing = new Map(Object.entries(before).map(([key, value]) => [key, JSON.stringify(value)]));
37
+ const view = createMemoryStorage(backing);
38
+ // Staging already owns its isolated map; nested transactions need no CAS.
39
+ const { mutate: _mutate, ...plain } = view;
40
+ staged.add(plain);
41
+ const result = await task(plain);
42
+ const next = Object.fromEntries([...backing].map(([key, raw]) => [key, JSON.parse(raw)]));
43
+ const changed = signature(next) !== expected;
44
+ const committed = await storage.mutate(prefix, (current) => signature(current) !== expected
45
+ ? { result: false } : { ...(changed ? { next } : {}), result: true });
46
+ if (committed) return result;
47
+ }
48
+ throw Object.assign(new Error('ledger transaction conflict; retry the operation'), { code: 'LEDGER_CONFLICT' });
49
+ }