@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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +488 -0
- package/package.json +98 -0
- package/src/archive.d.ts +47 -0
- package/src/archive.js +53 -0
- package/src/environment.d.ts +66 -0
- package/src/environment.js +715 -0
- package/src/evidence.d.ts +122 -0
- package/src/evidence.js +71 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/ledger.d.ts +354 -0
- package/src/ledger.js +1397 -0
- package/src/recall.d.ts +74 -0
- package/src/recall.js +160 -0
- package/src/retention.d.ts +24 -0
- package/src/retention.js +91 -0
- package/src/schemas/evidence.d.ts +54 -0
- package/src/schemas/evidence.js +32 -0
- package/src/schemas/ledger.d.ts +515 -0
- package/src/schemas/ledger.js +357 -0
- package/src/schemas/patch.d.ts +129 -0
- package/src/schemas/patch.js +135 -0
- package/src/storage/memory.d.ts +86 -0
- package/src/storage/memory.js +118 -0
- package/src/storage/slot.d.ts +30 -0
- package/src/storage/slot.js +107 -0
- package/src/storage/transaction.d.ts +33 -0
- package/src/storage/transaction.js +49 -0
|
@@ -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
|
+
}
|