@volter/world-core 2.0.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 +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
package/dist/src/log.js
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
// THE LOG (docs/concepts/the-model.md). One kind of entry, one log per
|
|
2
|
+
// branch, and a branch is a parent plus a position in the parent's log:
|
|
3
|
+
//
|
|
4
|
+
// parent log `events.jsonl` what the branch inherited — the origin's history as fetched, the
|
|
5
|
+
// placeholder's default data, and every entry of this branch that
|
|
6
|
+
// LANDED on the parent (a push, or a deploy at a root), with its
|
|
7
|
+
// receipt. Written only by fetch, refresh and landing; never by a
|
|
8
|
+
// write the app made.
|
|
9
|
+
// branch log `actions.jsonl` this branch's own entries, in order: every write the app made,
|
|
10
|
+
// recorded as it was served. Written only by the write path.
|
|
11
|
+
// branch.json the parent: the placeholder, a base world root at a position,
|
|
12
|
+
// or a served world's URL. Absent = an unbranched world whose
|
|
13
|
+
// parent is whatever its parent log holds.
|
|
14
|
+
// views/<hash>.json immutable verified segment ranges and projection layout.
|
|
15
|
+
// checkpoints/latest.json the tree bound to the exact inherited view: a read is
|
|
16
|
+
// the checkpoint plus the entries since, never a replay from zero.
|
|
17
|
+
//
|
|
18
|
+
// Landing replaces confirm-and-suppress: an entry that lands is COPIED to the parent log with its
|
|
19
|
+
// receipt (and under the vendor's id when the vendor minted one, `aliasOf` naming the local id),
|
|
20
|
+
// and the fold skips a branch entry whose id the parent already holds. Nothing is suppressed,
|
|
21
|
+
// rebound or quarantined by a bookkeeping row; `unpushed` is the branch log minus what the parent
|
|
22
|
+
// holds. The parent log accepts the v1 observed-event rows too (`toEntry` reads a delta event as
|
|
23
|
+
// an entry; a non-delta observation folds nothing and is kept for `listEvents`), so a protocol 1
|
|
24
|
+
// pack's own fold of the logs keeps working while it is deprecated.
|
|
25
|
+
import { captureHistory, historyChanges, historyEntryTime, historyEntries, historyLength, historyPrefix, inheritedHistory, inheritedHistoryKey, originHead, readHistoryView, replaceHistoryOrigin, saveHistoryView, validateHistoryOrigin } from "./history.js";
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { canonicalStatePath, checkParent, commitParentPin, pinParent, withAncestryLock } from "./ancestry.js";
|
|
28
|
+
import { resolveReferences } from "./references.js";
|
|
29
|
+
import { hashFieldValue } from "./hash.js";
|
|
30
|
+
import { worldPaths } from "./storage.js";
|
|
31
|
+
import { getActiveWorldStore } from "./world-store.js";
|
|
32
|
+
export const CHECKPOINT_EVERY = 100;
|
|
33
|
+
export const DELTA_SUFFIX = '.delta';
|
|
34
|
+
export function parentLogPath(service, root) { return worldPaths(service, root).events; }
|
|
35
|
+
export function branchLogPath(service, root) { return join(dirname(worldPaths(service, root).events), 'actions.jsonl'); }
|
|
36
|
+
export function branchMetaPath(service, root) { return join(dirname(worldPaths(service, root).events), 'branch.json'); }
|
|
37
|
+
/** The fetched cache of a URL parent's whole log — what `fetch` extends and a read folds up to the position. */
|
|
38
|
+
export function originLogPath(service, root) { return join(dirname(worldPaths(service, root).events), 'origin.jsonl'); }
|
|
39
|
+
/** A parent is at a URL when it is not a path on this machine. */
|
|
40
|
+
export function isUrlParent(at) { return /^[a-z][a-z0-9+.-]*:\/\//i.test(at); }
|
|
41
|
+
export function checkpointPath(service, root) { return join(dirname(worldPaths(service, root).events), 'checkpoints', 'latest.json'); }
|
|
42
|
+
const parsedRows = new WeakMap();
|
|
43
|
+
function parseLines(path, lines, offset, out) {
|
|
44
|
+
for (const [i, line] of lines.entries()) {
|
|
45
|
+
if (!line.trim())
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
out.push(JSON.parse(line));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new Error(`${path}:${offset + i + 1}: invalid JSONL row: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A log's content from the start of its last non-blank line to its end: what must still stand at that byte offset
|
|
56
|
+
* for the log to have only grown since. `undefined` when the content does not end a line or holds no entry. */
|
|
57
|
+
function tailOf(content) {
|
|
58
|
+
if (!content.endsWith('\n'))
|
|
59
|
+
return undefined;
|
|
60
|
+
// blank as parseLines reads it: a line that trims to nothing (a CRLF's `\r`, spaces) is no entry
|
|
61
|
+
let start = content.lastIndexOf('\n', content.length - 2) + 1;
|
|
62
|
+
while (start > 0 && !content.slice(start, content.indexOf('\n', start)).trim())
|
|
63
|
+
start = content.lastIndexOf('\n', start - 2) + 1;
|
|
64
|
+
if (!content.slice(start, content.indexOf('\n', start)).trim())
|
|
65
|
+
return undefined;
|
|
66
|
+
return content.slice(start);
|
|
67
|
+
}
|
|
68
|
+
function readRows(path) {
|
|
69
|
+
const store = getActiveWorldStore();
|
|
70
|
+
const cache = parsedRows.get(store) ?? parsedRows.set(store, new Map()).get(store);
|
|
71
|
+
const held = cache.get(path);
|
|
72
|
+
if (store.readRange && held?.bytes !== undefined && held.tail !== undefined) {
|
|
73
|
+
const chunk = store.readRange(path, held.bytes - Buffer.byteLength(held.tail));
|
|
74
|
+
if (chunk === null) {
|
|
75
|
+
cache.delete(path);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const appended = chunk.startsWith(held.tail) ? chunk.slice(held.tail.length) : undefined;
|
|
79
|
+
if (appended === '')
|
|
80
|
+
return held.rows.slice();
|
|
81
|
+
// only whole appended lines extend the rows; a torn last line is read (and refused) as the whole file reads it
|
|
82
|
+
if (appended !== undefined && appended.endsWith('\n')) {
|
|
83
|
+
const rows = held.rows.slice();
|
|
84
|
+
const lines = appended.split('\n');
|
|
85
|
+
parseLines(path, lines, held.lines, rows);
|
|
86
|
+
const tail = tailOf(held.tail + appended);
|
|
87
|
+
cache.set(path, { rows, lines: held.lines + lines.length - 1, bytes: held.bytes + Buffer.byteLength(appended), tail });
|
|
88
|
+
return rows.slice();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const content = store.read(path);
|
|
92
|
+
if (content === null) {
|
|
93
|
+
cache.delete(path);
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
if (!store.readRange && held?.content !== undefined) {
|
|
97
|
+
if (held.content === content)
|
|
98
|
+
return held.rows.slice();
|
|
99
|
+
// an append after a whole row extends the rows already parsed; anything else is parsed afresh
|
|
100
|
+
if (held.content.endsWith('\n') && content.startsWith(held.content)) {
|
|
101
|
+
const rows = held.rows.slice();
|
|
102
|
+
parseLines(path, content.slice(held.content.length).split('\n'), held.lines, rows);
|
|
103
|
+
cache.set(path, { content, rows, lines: held.lines + content.slice(held.content.length).split('\n').length - 1 });
|
|
104
|
+
return rows.slice();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const rows = [];
|
|
108
|
+
const lines = content.split('\n');
|
|
109
|
+
parseLines(path, lines, 0, rows);
|
|
110
|
+
const tail = store.readRange ? tailOf(content) : undefined;
|
|
111
|
+
cache.set(path, tail !== undefined ? { rows, lines: lines.length - 1, bytes: Buffer.byteLength(content), tail } : { content, rows, lines: lines.length - 1 });
|
|
112
|
+
return rows.slice();
|
|
113
|
+
}
|
|
114
|
+
export function readBranchMeta(service, root) {
|
|
115
|
+
const raw = getActiveWorldStore().read(branchMetaPath(service, root));
|
|
116
|
+
if (raw === null)
|
|
117
|
+
return null;
|
|
118
|
+
const meta = JSON.parse(raw);
|
|
119
|
+
// a pointer written before positions were one number: the parent's whole log is parent then branch
|
|
120
|
+
if (meta.parent && meta.parent.at === undefined && meta.parent.root !== undefined)
|
|
121
|
+
meta.parent = { at: meta.parent.root, position: (meta.parent.parentCount ?? 0) + (meta.parent.branchCount ?? 0) };
|
|
122
|
+
return meta;
|
|
123
|
+
}
|
|
124
|
+
export function writeBranchMeta(service, meta, root) {
|
|
125
|
+
withAncestryLock(() => {
|
|
126
|
+
if (meta.parent && (!Number.isInteger(meta.parent.position) || meta.parent.position < 0))
|
|
127
|
+
throw new Error('Invalid branch position');
|
|
128
|
+
const path = branchMetaPath(service, root);
|
|
129
|
+
const old = readBranchMeta(service, root);
|
|
130
|
+
// Tracking can name a different owner from the immediate fork parent. Retain its immutable
|
|
131
|
+
// view through the existing view dependency mechanism, including empty origin snapshots.
|
|
132
|
+
for (const origin of [meta.origin, meta.fetchedOrigin])
|
|
133
|
+
if (origin) {
|
|
134
|
+
validateHistoryOrigin(origin);
|
|
135
|
+
checkParent(origin.directory, origin.generation);
|
|
136
|
+
const descriptor = historyPrefix(readHistoryView(origin.directory, origin.view), origin.position);
|
|
137
|
+
saveHistoryView(worldPaths(service, root).dir, { ...descriptor, origin: { ...origin, depth: 0 } });
|
|
138
|
+
}
|
|
139
|
+
const previousParents = [...(old?.previousParents ?? [])];
|
|
140
|
+
if (old?.parent?.directory && old.parent.generation)
|
|
141
|
+
previousParents.push({ directory: old.parent.directory, generation: old.parent.generation });
|
|
142
|
+
if (previousParents.length)
|
|
143
|
+
meta = { ...meta, previousParents: [...new Map(previousParents.map(p => [p.directory, p])).values()] };
|
|
144
|
+
if (meta.parent && !isUrlParent(meta.parent.at)) {
|
|
145
|
+
const directory = canonicalStatePath(worldPaths(service, meta.parent.at).dir);
|
|
146
|
+
const view = meta.parent.view ?? captureHistory(service, meta.parent.at).view;
|
|
147
|
+
const viewDirectory = meta.parent.viewDirectory ?? directory;
|
|
148
|
+
if (meta.parent.viewDirectory && canonicalStatePath(viewDirectory) !== canonicalStatePath(worldPaths(service, root).dir))
|
|
149
|
+
throw new Error('Composed history view must belong to this branch');
|
|
150
|
+
const descriptor = readHistoryView(viewDirectory, view);
|
|
151
|
+
historyPrefix(descriptor, meta.parent.position);
|
|
152
|
+
const parent = meta.parent;
|
|
153
|
+
const generation = pinParent(directory, path, parent.generation);
|
|
154
|
+
meta = { ...meta, parent: { ...parent, view, at: canonicalStatePath(parent.at), directory, generation } };
|
|
155
|
+
}
|
|
156
|
+
if (meta.parent && isUrlParent(meta.parent.at) && !meta.parent.view) {
|
|
157
|
+
const head = originHead(service, root);
|
|
158
|
+
if (head)
|
|
159
|
+
meta = { ...meta, parent: { ...meta.parent, view: head.view, remoteView: head.remoteView } };
|
|
160
|
+
else if (meta.parent.position === 0) {
|
|
161
|
+
const empty = captureHistory(service, root);
|
|
162
|
+
meta = { ...meta, parent: { ...meta.parent, view: empty.view } };
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
throw new Error('Origin has no completed immutable history view');
|
|
166
|
+
}
|
|
167
|
+
getActiveWorldStore().mkdir(dirname(path));
|
|
168
|
+
getActiveWorldStore().writeAtomic(path, `${JSON.stringify(meta, null, 2)}\n`);
|
|
169
|
+
if (meta.parent?.directory && !isUrlParent(meta.parent.at))
|
|
170
|
+
commitParentPin(meta.parent.directory, path);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** A v1 observed row as an entry: a delta event folds its `changed.*.after`; anything else folds
|
|
174
|
+
* nothing and rides along for `listEvents`. An entry row is itself. */
|
|
175
|
+
export function toEntry(row) {
|
|
176
|
+
if (typeof row.op === 'string')
|
|
177
|
+
return row;
|
|
178
|
+
const event = row;
|
|
179
|
+
const data = (event.data ?? {});
|
|
180
|
+
const changed = data.changed;
|
|
181
|
+
const isDelta = typeof event.type === 'string' && event.type.endsWith(DELTA_SUFFIX) && changed !== undefined && typeof changed === 'object';
|
|
182
|
+
const isEgress = typeof event.type === 'string' && (event.type.endsWith('.write.intent') || event.type.endsWith('.write.result'));
|
|
183
|
+
// a delta row folds what changed; a flat observed row (a connector's snapshot, a fold of a webhook)
|
|
184
|
+
// folds its data as the subject's fields — and a `changed` map riding on it (`{ field: { after } }`)
|
|
185
|
+
// folds its afters over them, so the shape means the same thing on any row; an egress record folds nothing
|
|
186
|
+
const deltaShaped = changed !== undefined && changed !== null && typeof changed === 'object' && !Array.isArray(changed)
|
|
187
|
+
&& Object.values(changed).every((v) => v !== null && typeof v === 'object' && ('after' in v || 'before' in v));
|
|
188
|
+
const afters = () => Object.fromEntries(Object.entries(changed).map(([k, v]) => [k, v?.after]));
|
|
189
|
+
const { changed: _changed, ...flat } = data;
|
|
190
|
+
const fields = isEgress ? undefined : isDelta ? afters() : deltaShaped ? { ...flat, ...afters() } : data;
|
|
191
|
+
return {
|
|
192
|
+
id: event.id, service: event.service, op: 'set', subject: event.subject, occurredAt: event.occurredAt,
|
|
193
|
+
...(fields ? { fields } : {}), event,
|
|
194
|
+
// the default data's rows are the placeholder origin's (docs/concepts/the-model.md#a-branch-is-a-position-not-a-copy, "default data"): a read hides them from the log
|
|
195
|
+
...(event.origin === 'virtual' || data.provenance === 'placeholder' ? { provenance: 'placeholder' } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** An entry as a v1 observed row: its own row when it came from one, else a delta event. */
|
|
199
|
+
export function toEvent(entry) {
|
|
200
|
+
if (entry.event)
|
|
201
|
+
return entry.event;
|
|
202
|
+
const fields = entry.fields ?? {};
|
|
203
|
+
return {
|
|
204
|
+
id: entry.id, service: entry.service, type: `${entry.service}.${entry.subject.type}${DELTA_SUFFIX}`, schemaVersion: 1,
|
|
205
|
+
idempotencyKey: entry.id, occurredAt: entry.occurredAt, observedAt: entry.receipt?.at ?? entry.occurredAt,
|
|
206
|
+
origin: entry.receipt ? 'external' : 'connector', subject: entry.subject,
|
|
207
|
+
...(entry.actor ? { actor: entry.actor } : {}),
|
|
208
|
+
...(entry.receipt?.externalId ? { external: { provider: entry.service, id: entry.receipt.externalId, ...(entry.receipt.url ? { url: entry.receipt.url } : {}) } } : {}),
|
|
209
|
+
data: { changed: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { after: v }])), ...(entry.receipt ? { receipt: entry.receipt } : {}) },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** This root's own parent-log rows, as entries. */
|
|
213
|
+
function ownParentEntries(service, root) {
|
|
214
|
+
return readRows(parentLogPath(service, root)).map(toEntry);
|
|
215
|
+
}
|
|
216
|
+
/** This root's own branch-log rows. */
|
|
217
|
+
export function branchEntries(service, root) {
|
|
218
|
+
return readRows(branchLogPath(service, root));
|
|
219
|
+
}
|
|
220
|
+
/** The parent as this branch sees it: the base world's view at the branch position (its parent
|
|
221
|
+
* log to `parentCount`, its branch log to `branchCount`), then everything this root's own parent
|
|
222
|
+
* log holds (fetched, refreshed or landed since). An unbranched root is just its own parent log. */
|
|
223
|
+
export function parentEntries(service, root, seen = new Set()) {
|
|
224
|
+
const view = inheritedHistory(service, root);
|
|
225
|
+
return [...(view ? historyEntries(view) : []), ...ownParentEntries(service, root)];
|
|
226
|
+
}
|
|
227
|
+
/** The fetched cache of a URL parent's whole log (every entry fetched so far, past the position too). */
|
|
228
|
+
export function originEntries(service, root) {
|
|
229
|
+
const head = originHead(service, root);
|
|
230
|
+
return head ? historyEntries(readHistoryView(worldPaths(service, root).dir, head.view)) : readRows(originLogPath(service, root)).map(toEntry);
|
|
231
|
+
}
|
|
232
|
+
/** Extend the cache with one of the parent's entries; deduplicated by id. */
|
|
233
|
+
export function appendOriginEntry(entry, root) {
|
|
234
|
+
const path = originLogPath(entry.service, root);
|
|
235
|
+
const store = getActiveWorldStore();
|
|
236
|
+
store.mkdir(dirname(path));
|
|
237
|
+
return withAncestryLock(() => store.withLock(`${path}.lock`, () => {
|
|
238
|
+
if (readRows(path).some((r) => r.id === entry.id))
|
|
239
|
+
return { appended: false };
|
|
240
|
+
store.append(path, `${JSON.stringify(entry)}\n`);
|
|
241
|
+
return { appended: true };
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
/** Where the parent stands now: the length of its whole log (live at a path, the cache for a URL). */
|
|
245
|
+
export function parentPosition(service, root) {
|
|
246
|
+
const meta = readBranchMeta(service, root);
|
|
247
|
+
if (!meta?.parent)
|
|
248
|
+
return null;
|
|
249
|
+
return isUrlParent(meta.parent.at) ? originEntries(service, root).length : wholeLog(service, meta.parent.at).length;
|
|
250
|
+
}
|
|
251
|
+
/** Ids the parent holds, including the local ids its landed copies stand for. */
|
|
252
|
+
export function landedIds(parent) {
|
|
253
|
+
const out = new Set();
|
|
254
|
+
for (const e of parent) {
|
|
255
|
+
out.add(e.id);
|
|
256
|
+
if (e.landsId)
|
|
257
|
+
out.add(e.landsId);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/** The vendor-minted id per local subject: every landed copy that carries `aliasOf`. A local id the
|
|
262
|
+
* vendor itself holds names the vendor's subject, never the alias: a World that minted a local id
|
|
263
|
+
* before it had observed the vendor's own subject under that id (its tree was behind) must not fold
|
|
264
|
+
* the vendor's subject onto the landed one, nor send a caller who addresses the vendor's id there. */
|
|
265
|
+
export function aliasesFrom(parent) {
|
|
266
|
+
const vendorHeld = new Set();
|
|
267
|
+
for (const e of parent)
|
|
268
|
+
if (e.batch !== undefined || e.operation === 'observed' || e.event !== undefined || e.landsId !== undefined)
|
|
269
|
+
vendorHeld.add(`${e.subject.type}:${e.subject.id}`);
|
|
270
|
+
const out = new Map();
|
|
271
|
+
for (const e of parent) {
|
|
272
|
+
if (!e.aliasOf || e.aliasOf === e.subject.id)
|
|
273
|
+
continue;
|
|
274
|
+
const from = `${e.subject.type}:${e.aliasOf}`;
|
|
275
|
+
if (!vendorHeld.has(from))
|
|
276
|
+
out.set(from, e.subject.id);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
const META = new Set(['id', 'type', 'updatedAt']);
|
|
281
|
+
/** Last write per field. `deleted: true` is the tombstone a delete carries; it holds until the subject
|
|
282
|
+
* is written again (a create under the same address resurrects it — the write need not say so). */
|
|
283
|
+
function overlay(tree, type, id, fields, at) {
|
|
284
|
+
const key = `${type}:${id}`;
|
|
285
|
+
const existing = tree.get(key) ?? { type, id, updatedAt: at, fields: {} };
|
|
286
|
+
const merged = { ...existing.fields, ...fields };
|
|
287
|
+
if (existing.fields.deleted === true && fields.deleted !== true)
|
|
288
|
+
delete merged.deleted;
|
|
289
|
+
tree.set(key, { ...existing, updatedAt: at, fields: merged });
|
|
290
|
+
}
|
|
291
|
+
/** Fold entries onto a tree, in order: `set` overlays its fields and applies its projection; a
|
|
292
|
+
* reverted entry folds nothing; a `confirm` row (v1) folds nothing. `skip` names entries the
|
|
293
|
+
* parent already holds. */
|
|
294
|
+
export function foldEntries(tree, entries, skip = new Set()) {
|
|
295
|
+
const reverted = new Set(entries.filter((e) => e.op === 'revert' && e.revertsActionId).map((e) => e.revertsActionId));
|
|
296
|
+
for (const e of entries) {
|
|
297
|
+
if (e.op !== 'set' || reverted.has(e.id) || skip.has(e.id))
|
|
298
|
+
continue;
|
|
299
|
+
// a landed copy the vendor did not take (refused by a check, failed at the vendor) is on the log with
|
|
300
|
+
// its receipt, and not in the tree: the tree holds what the vendor holds or what is still queued
|
|
301
|
+
if (e.landsId && (e.receipt?.status === 'refused' || e.receipt?.status === 'failed'))
|
|
302
|
+
continue;
|
|
303
|
+
if (e.fields)
|
|
304
|
+
overlay(tree, e.subject.type, e.subject.id, e.fields, e.occurredAt);
|
|
305
|
+
if (e.projection) {
|
|
306
|
+
for (const c of e.projection.creates ?? [])
|
|
307
|
+
overlay(tree, c.type, c.id, c.fields, e.occurredAt);
|
|
308
|
+
for (const u of e.projection.updates ?? [])
|
|
309
|
+
overlay(tree, u.type, u.id, u.fields, e.occurredAt);
|
|
310
|
+
for (const d of e.projection.deletes ?? [])
|
|
311
|
+
tree.delete(`${d.type}:${d.id}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Fold aliases: whatever still sits under a local id (a later local update against the id the
|
|
316
|
+
* caller holds) folds onto the vendor's row. */
|
|
317
|
+
export function foldAliases(tree, aliases, service, only) {
|
|
318
|
+
for (const [fromKey, toId] of aliases) {
|
|
319
|
+
const stray = tree.get(fromKey);
|
|
320
|
+
if (!stray)
|
|
321
|
+
continue;
|
|
322
|
+
overlay(tree, stray.type, toId, stray.fields, stray.updatedAt);
|
|
323
|
+
tree.delete(fromKey);
|
|
324
|
+
}
|
|
325
|
+
// declared references follow the adoption (docs/contributing/architecture.md#alias-aware-lookup-at-the-request-boundary), whatever order the entries folded in
|
|
326
|
+
if (service !== undefined && aliases.size > 0) {
|
|
327
|
+
// `only`: an extension's touched subjects, the only ones an earlier fold has not already resolved
|
|
328
|
+
for (const s of only ? [...only].map((k) => tree.get(k)).filter((held) => held !== undefined) : tree.values()) {
|
|
329
|
+
const resolved = resolveReferences(service, s.type, s.fields, aliases);
|
|
330
|
+
if (resolved !== s.fields)
|
|
331
|
+
s.fields = resolved;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** The tree as resources: `id`, `type` and `updatedAt` are the SUBJECT's. A resource whose own fields
|
|
336
|
+
* carry a different `id`, `type` or `updatedAt` (a Jira issue has a key and a numeric id; a Stripe
|
|
337
|
+
* price has a `type` of its own) keeps them BESIDE the row, on a non-enumerable property: a spread,
|
|
338
|
+
* `Object.entries`, JSON and a conformance sweep never see them, so a pack that serves the row as it
|
|
339
|
+
* stands serves exactly the vendor's fields it wrote; `ownFields` restores them for a pack that wants
|
|
340
|
+
* the vendor's own values in the vendor's positions. */
|
|
341
|
+
const OWN = Symbol.for('volter.resource.own');
|
|
342
|
+
export function treeResources(tree) {
|
|
343
|
+
return [...tree.values()].map((s) => {
|
|
344
|
+
const out = { id: s.id, type: s.type, updatedAt: s.updatedAt };
|
|
345
|
+
const own = {};
|
|
346
|
+
for (const [k, v] of Object.entries(s.fields)) {
|
|
347
|
+
if (!META.has(k))
|
|
348
|
+
out[k] = v;
|
|
349
|
+
else if (v !== undefined && v !== out[k])
|
|
350
|
+
own[k] = v;
|
|
351
|
+
}
|
|
352
|
+
if (Object.keys(own).length)
|
|
353
|
+
Object.defineProperty(out, OWN, { value: own, enumerable: false });
|
|
354
|
+
return out;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** A copy of a resource a reader may change: its fields, and the vendor's own id/type/updatedAt it keeps beside them. */
|
|
358
|
+
export function copyResource(r) {
|
|
359
|
+
const out = { ...r };
|
|
360
|
+
const own = r[OWN];
|
|
361
|
+
if (own !== undefined)
|
|
362
|
+
Object.defineProperty(out, OWN, { value: own, enumerable: false });
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
/** A resource's own fields, the vendor's way: the subject's address stripped, its own `id`/`type`/`updatedAt` restored when it had them. */
|
|
366
|
+
export function ownFields(r) {
|
|
367
|
+
const { id: _id, type: _type, updatedAt: _at, ...rest } = r;
|
|
368
|
+
const own = (r[OWN] ?? {});
|
|
369
|
+
// restored first: a vendor's object leads with its id, and a reader's grep may count on the order
|
|
370
|
+
return { ...(own.id !== undefined ? { id: own.id } : {}), ...(own.type !== undefined ? { type: own.type } : {}), ...rest, ...(own.updatedAt !== undefined ? { updatedAt: own.updatedAt } : {}) };
|
|
371
|
+
}
|
|
372
|
+
export function dropCheckpoint(service, root) { getActiveWorldStore().remove(checkpointPath(service, root)); }
|
|
373
|
+
/** Preserve each ancestor's observed-then-local overlay, including landed IDs and aliases. */
|
|
374
|
+
export function foldHistory(entries, layout, service) {
|
|
375
|
+
const baseCount = layout.base ? historyLength(layout.base) : 0;
|
|
376
|
+
const tree = layout.base ? foldHistory(entries.slice(0, baseCount), layout.base, service) : new Map();
|
|
377
|
+
const parent = entries.slice(0, baseCount + layout.parent);
|
|
378
|
+
foldEntries(tree, parent.slice(baseCount));
|
|
379
|
+
foldEntries(tree, entries.slice(baseCount + layout.parent, baseCount + layout.parent + layout.branch), landedIds(parent));
|
|
380
|
+
foldAliases(tree, aliasesFrom(parent), service);
|
|
381
|
+
return tree;
|
|
382
|
+
}
|
|
383
|
+
export function readTree(service, root, opts = {}) {
|
|
384
|
+
return treeResources(readTreeMap(service, root, opts));
|
|
385
|
+
}
|
|
386
|
+
/** The upstream view for observation diffing, without this root's local overlay. Inherited
|
|
387
|
+
* history keeps its own layout: an ancestor's local writes are part of our pinned base. */
|
|
388
|
+
export function readParentTreeMap(service, root) {
|
|
389
|
+
return withAncestryLock(() => {
|
|
390
|
+
const inherited = inheritedHistory(service, root);
|
|
391
|
+
const own = ownParentEntries(service, root);
|
|
392
|
+
return foldHistory([...(inherited ? historyEntries(inherited) : []), ...own], {
|
|
393
|
+
...(inherited ? { base: inherited.layout } : {}), parent: own.length, branch: 0,
|
|
394
|
+
}, service);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
const treeMemos = new WeakMap();
|
|
398
|
+
/** How many extensions a memo remembers the touched subjects of; a projection further behind refolds. */
|
|
399
|
+
const TREE_STEPS_KEPT = 64;
|
|
400
|
+
function statKey(path) { const s = getActiveWorldStore().stat(path); return s ? `${s.size}:${s.mtimeMs}` : '-'; }
|
|
401
|
+
/** What a service's PARENT side at `root` is folded from (its parent log, branch.json and inherited history), as one
|
|
402
|
+
* string: while it stands, its aliases and landed ids are the same, whatever the branch's own log does. */
|
|
403
|
+
export function parentStamp(service, root) { return parentKeyOf(service, root); }
|
|
404
|
+
function parentKeyOf(service, root) { return `${statKey(parentLogPath(service, root))}|${getActiveWorldStore().read(branchMetaPath(service, root)) ?? ''}|${inheritedHistoryKey(service, root)}`; }
|
|
405
|
+
/** What a service's tree (and every subject's history) at `root` is folded from, as one string: while it
|
|
406
|
+
* stands, nothing was written. A pack that projects the tree into its own state memoizes on it. */
|
|
407
|
+
export function treeStamp(service, root) { return `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}`; }
|
|
408
|
+
const cloneTree = (tree) => new Map([...tree].map(([k, s]) => [k, { ...s, fields: { ...s.fields } }]));
|
|
409
|
+
const memoStamp = (m) => `${m.parentKey}#${m.branchKey}`;
|
|
410
|
+
/** The subject keys entries write (as foldEntries folds them), and the vendor rows an alias moves a local one onto. */
|
|
411
|
+
function touchedKeys(entries, aliases) {
|
|
412
|
+
const keys = new Set();
|
|
413
|
+
for (const e of entries) {
|
|
414
|
+
if (e.fields)
|
|
415
|
+
keys.add(`${e.subject.type}:${e.subject.id}`);
|
|
416
|
+
for (const c of e.projection?.creates ?? [])
|
|
417
|
+
keys.add(`${c.type}:${c.id}`);
|
|
418
|
+
for (const u of e.projection?.updates ?? [])
|
|
419
|
+
keys.add(`${u.type}:${u.id}`);
|
|
420
|
+
for (const d of e.projection?.deletes ?? [])
|
|
421
|
+
keys.add(`${d.type}:${d.id}`);
|
|
422
|
+
}
|
|
423
|
+
for (const key of [...keys]) {
|
|
424
|
+
const to = aliases.get(key);
|
|
425
|
+
if (to !== undefined)
|
|
426
|
+
keys.add(`${key.slice(0, key.indexOf(':'))}:${to}`);
|
|
427
|
+
}
|
|
428
|
+
return keys;
|
|
429
|
+
}
|
|
430
|
+
/** The keys `fold` inserts into `tree` (a Map puts an inserted key at its end, so a key deleted and set again moves
|
|
431
|
+
* there too), in the order they then stand, each once. */
|
|
432
|
+
function insertionsDuring(tree, fold) {
|
|
433
|
+
const order = new Map();
|
|
434
|
+
const own = tree;
|
|
435
|
+
own.set = (key, value) => { if (!tree.has(key)) {
|
|
436
|
+
order.delete(key);
|
|
437
|
+
order.set(key, true);
|
|
438
|
+
} return Map.prototype.set.call(tree, key, value); };
|
|
439
|
+
try {
|
|
440
|
+
fold();
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
delete own.set;
|
|
444
|
+
}
|
|
445
|
+
return [...order.keys()].filter((k) => tree.has(k));
|
|
446
|
+
}
|
|
447
|
+
export function readTreeMap(service, root, opts = {}) {
|
|
448
|
+
if (opts.until !== undefined || opts.at !== undefined || opts.view !== undefined)
|
|
449
|
+
return readTreeMapFull(service, root, opts);
|
|
450
|
+
const read = currentTree(service, root);
|
|
451
|
+
return read.memo ? cloneTree(read.tree) : read.tree;
|
|
452
|
+
}
|
|
453
|
+
/** The current tree: the memo's own (never to be handed out) when the memo holds it, else a fresh fold. */
|
|
454
|
+
function currentTree(service, root) {
|
|
455
|
+
const store = getActiveWorldStore();
|
|
456
|
+
let memos = treeMemos.get(store);
|
|
457
|
+
if (!memos) {
|
|
458
|
+
memos = new Map();
|
|
459
|
+
treeMemos.set(store, memos);
|
|
460
|
+
}
|
|
461
|
+
const slot = `${service}\u0000${root ?? ''}`;
|
|
462
|
+
// A hit needs no coordinator: a memo is only ever stored from a fold made under it, and while the
|
|
463
|
+
// facts still match that fold, nothing was written since. A writer mid-batch has moved them.
|
|
464
|
+
const unlocked = memos.get(slot);
|
|
465
|
+
if (unlocked && unlocked.parentKey === parentKeyOf(service, root) && unlocked.branchKey === statKey(branchLogPath(service, root)))
|
|
466
|
+
return { tree: unlocked.tree, memo: unlocked };
|
|
467
|
+
return withAncestryLock(() => {
|
|
468
|
+
const parentKey = parentKeyOf(service, root);
|
|
469
|
+
const branchKey = statKey(branchLogPath(service, root));
|
|
470
|
+
const held = memos.get(slot);
|
|
471
|
+
if (held && held.parentKey === parentKey && held.branchKey === branchKey)
|
|
472
|
+
return { tree: held.tree, memo: held };
|
|
473
|
+
const branch = branchEntries(service, root);
|
|
474
|
+
const last = (entries) => (entries.length ? entries[entries.length - 1].id : null);
|
|
475
|
+
if (held && held.parentKey === parentKey && branch.length >= held.branchCount
|
|
476
|
+
&& (held.branchCount === 0 ? held.lastBranchId === null : branch[held.branchCount - 1]?.id === held.lastBranchId)
|
|
477
|
+
&& !branch.slice(held.branchCount).some((e) => e.op === 'revert')) {
|
|
478
|
+
const fresh = branch.slice(held.branchCount);
|
|
479
|
+
const keys = touchedKeys(fresh, held.aliases);
|
|
480
|
+
const from = memoStamp(held);
|
|
481
|
+
let appended;
|
|
482
|
+
try {
|
|
483
|
+
appended = insertionsDuring(held.tree, () => {
|
|
484
|
+
foldEntries(held.tree, fresh, held.landed);
|
|
485
|
+
foldAliases(held.tree, held.aliases, service, keys);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
memos.delete(slot);
|
|
490
|
+
throw error;
|
|
491
|
+
} // a half-folded memo is never read again
|
|
492
|
+
Object.assign(held, { branchKey, branchCount: branch.length, lastBranchId: last(branch) });
|
|
493
|
+
held.steps.push({ from, to: memoStamp(held), keys, appended });
|
|
494
|
+
if (held.steps.length > TREE_STEPS_KEPT)
|
|
495
|
+
held.steps.splice(0, held.steps.length - TREE_STEPS_KEPT);
|
|
496
|
+
return { tree: held.tree, memo: held };
|
|
497
|
+
}
|
|
498
|
+
const tree = readTreeMapFull(service, root);
|
|
499
|
+
const parent = parentEntries(service, root);
|
|
500
|
+
// the facts are read again after the fold: a write that raced it leaves nothing memoized
|
|
501
|
+
if (parentKeyOf(service, root) === parentKey && statKey(branchLogPath(service, root)) === branchKey) {
|
|
502
|
+
const memo = { parentKey, branchKey, branchCount: branch.length, lastBranchId: last(branch), landed: landedIds(parent), aliases: aliasesFrom(parent), tree, steps: [] };
|
|
503
|
+
memos.set(slot, memo);
|
|
504
|
+
return { tree, memo };
|
|
505
|
+
}
|
|
506
|
+
return { tree };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/** What changed in a service's tree since `since` (a `treeStamp` an earlier read was made at): the subjects
|
|
510
|
+
* written since, as resources; the keys (`type:id`) of those no longer in the tree; and, of the changed, the keys
|
|
511
|
+
* now at the tree's end in the order they stand there (new to the tree, or deleted and made again), which a
|
|
512
|
+
* projection kept in the tree's order moves to its end in that order, updating the rest where they stand.
|
|
513
|
+
* `undefined` when this process cannot say (no memo, the parent moved, a revert, or `since` older than the memo
|
|
514
|
+
* remembers): the caller refolds. `stamp` is the state the answer brings the caller to. */
|
|
515
|
+
export function treeChangesSince(service, root, since) {
|
|
516
|
+
const { tree, memo } = currentTree(service, root);
|
|
517
|
+
if (!memo)
|
|
518
|
+
return undefined;
|
|
519
|
+
const stamp = memoStamp(memo);
|
|
520
|
+
if (since === stamp)
|
|
521
|
+
return { stamp, changed: [], removed: [], appended: [] };
|
|
522
|
+
const first = memo.steps.findIndex((s) => s.from === since);
|
|
523
|
+
if (first < 0)
|
|
524
|
+
return undefined;
|
|
525
|
+
const keys = new Set();
|
|
526
|
+
const order = new Map();
|
|
527
|
+
for (let i = first; i < memo.steps.length; i++) {
|
|
528
|
+
if (i > first && memo.steps[i].from !== memo.steps[i - 1].to)
|
|
529
|
+
return undefined;
|
|
530
|
+
for (const k of memo.steps[i].keys)
|
|
531
|
+
keys.add(k);
|
|
532
|
+
for (const k of memo.steps[i].appended) {
|
|
533
|
+
order.delete(k);
|
|
534
|
+
order.set(k, true);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (memo.steps[memo.steps.length - 1].to !== stamp)
|
|
538
|
+
return undefined;
|
|
539
|
+
const present = new Map();
|
|
540
|
+
const removed = [];
|
|
541
|
+
for (const k of keys) {
|
|
542
|
+
const s = tree.get(k);
|
|
543
|
+
if (s)
|
|
544
|
+
present.set(k, s);
|
|
545
|
+
else
|
|
546
|
+
removed.push(k);
|
|
547
|
+
}
|
|
548
|
+
return { stamp, changed: treeResources(present), removed, appended: [...order.keys()].filter((k) => tree.has(k)) };
|
|
549
|
+
}
|
|
550
|
+
function readTreeMapFull(service, root, opts = {}) {
|
|
551
|
+
return withAncestryLock(() => {
|
|
552
|
+
if (opts.at !== undefined || opts.view !== undefined) {
|
|
553
|
+
const view = opts.view ? readHistoryView(worldPaths(service, root).dir, opts.view) : captureHistory(service, root).descriptor;
|
|
554
|
+
const at = opts.at ?? historyLength(view.layout);
|
|
555
|
+
const entries = historyEntries(view);
|
|
556
|
+
if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch) {
|
|
557
|
+
let from = at;
|
|
558
|
+
let to = at;
|
|
559
|
+
const batch = entries[at].batch;
|
|
560
|
+
while (from > 0 && entries[from - 1]?.batch === batch)
|
|
561
|
+
from--;
|
|
562
|
+
while (to < entries.length && entries[to]?.batch === batch)
|
|
563
|
+
to++;
|
|
564
|
+
throw new Error(`position ${at} falls inside observation ${batch} — use ${from} or ${to}`);
|
|
565
|
+
}
|
|
566
|
+
const prefix = historyPrefix(view, at);
|
|
567
|
+
return foldHistory(entries.slice(0, at), prefix.layout, service);
|
|
568
|
+
}
|
|
569
|
+
// a cold read (no memo) trusts a checkpoint cut at exactly these log facts without re-hashing the logs
|
|
570
|
+
const facts = opts.until === undefined ? `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}` : null;
|
|
571
|
+
if (facts !== null) {
|
|
572
|
+
const held = getActiveWorldStore().read(checkpointPath(service, root));
|
|
573
|
+
const cut = held === null ? null : JSON.parse(held);
|
|
574
|
+
if (cut?.facts === facts)
|
|
575
|
+
return new Map(cut.subjects.map(s => [`${s.type}:${s.id}`, { ...s, fields: { ...s.fields } }]));
|
|
576
|
+
}
|
|
577
|
+
const inherited = inheritedHistory(service, root);
|
|
578
|
+
const base = inherited ? historyEntries(inherited) : [];
|
|
579
|
+
const own = ownParentEntries(service, root);
|
|
580
|
+
const parent = [...base, ...own];
|
|
581
|
+
const branchAll = branchEntries(service, root);
|
|
582
|
+
const until = opts.until === undefined ? -1 : branchAll.findIndex(e => e.id === opts.until);
|
|
583
|
+
const branch = until < 0 ? branchAll : branchAll.slice(0, until);
|
|
584
|
+
const parentDigest = hashFieldValue({ inherited, own });
|
|
585
|
+
const branchDigest = hashFieldValue(branch);
|
|
586
|
+
const raw = opts.until === undefined ? getActiveWorldStore().read(checkpointPath(service, root)) : null;
|
|
587
|
+
const cp = raw === null ? null : JSON.parse(raw);
|
|
588
|
+
if (cp?.parentDigest === parentDigest && cp.branchDigest === branchDigest)
|
|
589
|
+
return new Map(cp.subjects.map(s => [`${s.type}:${s.id}`, { ...s, fields: { ...s.fields } }]));
|
|
590
|
+
const layout = { ...(inherited ? { base: inherited.layout } : {}), parent: own.length, branch: branch.length };
|
|
591
|
+
const incremental = cp?.parentDigest === parentDigest && cp.branchCount <= branch.length && hashFieldValue(branch.slice(0, cp.branchCount)) === cp.branchDigest && !branch.slice(cp.branchCount).some(e => e.op === 'revert');
|
|
592
|
+
const tree = incremental ? new Map(cp.subjects.map(s => [`${s.type}:${s.id}`, { ...s, fields: { ...s.fields } }])) : foldHistory([...parent, ...branch], layout, service);
|
|
593
|
+
if (incremental) {
|
|
594
|
+
foldEntries(tree, branch.slice(cp.branchCount), landedIds(parent));
|
|
595
|
+
foldAliases(tree, aliasesFrom(parent), service);
|
|
596
|
+
}
|
|
597
|
+
if (opts.until === undefined && parent.length + branch.length > CHECKPOINT_EVERY) {
|
|
598
|
+
// the facts are the ones this fold read; recorded only if no write moved them while it folded
|
|
599
|
+
const now = `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}`;
|
|
600
|
+
getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest, branchDigest, branchCount: branch.length, subjects: [...tree.values()], ...(now === facts ? { facts } : {}) }));
|
|
601
|
+
}
|
|
602
|
+
return tree;
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
export function cutCheckpoint(service, root) {
|
|
606
|
+
return withAncestryLock(() => {
|
|
607
|
+
dropCheckpoint(service, root);
|
|
608
|
+
const subjects = [...readTreeMap(service, root).values()];
|
|
609
|
+
const inherited = inheritedHistory(service, root);
|
|
610
|
+
const own = ownParentEntries(service, root);
|
|
611
|
+
getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/** The branch's entries the parent does not hold: `set` rows, not reverted, not landed. */
|
|
615
|
+
export function unpushedEntries(service, root) {
|
|
616
|
+
const landed = landedIds(parentEntries(service, root));
|
|
617
|
+
const branch = branchEntries(service, root);
|
|
618
|
+
const reverted = new Set(branch.filter((e) => e.op === 'revert' && e.revertsActionId).map((e) => e.revertsActionId));
|
|
619
|
+
return branch.filter((e) => e.op === 'set' && !reverted.has(e.id) && !landed.has(e.id));
|
|
620
|
+
}
|
|
621
|
+
/** The position a branch is cut at: how far the parent view and the branch log reach right now. */
|
|
622
|
+
/** THE POSITION (contract "Just like Neon", 2): where this twin's whole log stands — one number, the
|
|
623
|
+
* count of entries, parent view then branch. Durable cuts pair it with an immutable view ID. */
|
|
624
|
+
export function position(service, root) {
|
|
625
|
+
return parentEntries(service, root).length + branchEntries(service, root).length;
|
|
626
|
+
}
|
|
627
|
+
/** The position of a twin's log at an instant: the entries that had occurred by then. */
|
|
628
|
+
export function positionAt(service, instant, root, view) {
|
|
629
|
+
const t = Date.parse(instant);
|
|
630
|
+
const log = view ? historyEntries(readHistoryView(worldPaths(service, root).dir, view)) : wholeLog(service, root);
|
|
631
|
+
let n = 0;
|
|
632
|
+
for (const e of log) {
|
|
633
|
+
if (historyEntryTime(e) <= t)
|
|
634
|
+
n += 1;
|
|
635
|
+
else
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
if (log.slice(n).some(e => historyEntryTime(e) <= t))
|
|
639
|
+
throw new Error('This instant selects noncontiguous history; use historyAtInstant to capture a view instead of a numeric offset');
|
|
640
|
+
// an observation is atomic: an instant inside one look resolves to the look's end
|
|
641
|
+
while (n > 0 && n < log.length && log[n].batch !== undefined && log[n].batch === log[n - 1].batch)
|
|
642
|
+
n += 1;
|
|
643
|
+
return n;
|
|
644
|
+
}
|
|
645
|
+
/** The batch a position would split, if any: `position` entries taken, and the next entry belongs to the
|
|
646
|
+
* same observation as the last one taken. Answers the boundaries a caller may use instead. */
|
|
647
|
+
export function splitsBatch(service, position, root) {
|
|
648
|
+
const log = wholeLog(service, root);
|
|
649
|
+
if (position <= 0 || position >= log.length)
|
|
650
|
+
return null;
|
|
651
|
+
const batch = log[position].batch;
|
|
652
|
+
if (batch === undefined || log[position - 1].batch !== batch)
|
|
653
|
+
return null;
|
|
654
|
+
let from = position - 1;
|
|
655
|
+
while (from > 0 && log[from - 1].batch === batch)
|
|
656
|
+
from -= 1;
|
|
657
|
+
let to = position;
|
|
658
|
+
while (to < log.length && log[to].batch === batch)
|
|
659
|
+
to += 1;
|
|
660
|
+
return { batch, from, to };
|
|
661
|
+
}
|
|
662
|
+
/** Refuse a position inside an observation, naming the boundaries. */
|
|
663
|
+
export function assertBatchBoundary(service, position, root) {
|
|
664
|
+
const split = splitsBatch(service, position, root);
|
|
665
|
+
if (split)
|
|
666
|
+
throw new Error(`position ${position} falls inside observation ${split.batch} (entries ${split.from + 1}–${split.to}); an observation is atomic — use ${split.from} or ${split.to}`);
|
|
667
|
+
}
|
|
668
|
+
/** A landed copy of `entry` for the parent log: the receipt on it, the vendor's id as its subject
|
|
669
|
+
* when one was minted. Deterministic id, so landing twice is the same row. */
|
|
670
|
+
export function landedCopy(entry, receipt, opts = {}) {
|
|
671
|
+
const subject = opts.subject ?? entry.subject;
|
|
672
|
+
const adopted = opts.vendorSubjectId !== undefined && opts.vendorSubjectId !== '' && opts.vendorSubjectId !== subject.id ? { type: subject.type, id: opts.vendorSubjectId } : subject;
|
|
673
|
+
const fields = opts.fields ?? entry.fields ?? {};
|
|
674
|
+
const contentHash = hashFieldValue({ landsId: entry.id, subject: adopted, fields, status: receipt.status });
|
|
675
|
+
const { event: _event, receipt: _receipt, aliasOf: _aliasOf, landsId: _landsId, ...body } = entry;
|
|
676
|
+
return {
|
|
677
|
+
...body,
|
|
678
|
+
id: `landed:${entry.service}:${adopted.type}:${adopted.id}:${entry.id}:${contentHash}`,
|
|
679
|
+
op: 'set', subject: adopted, fields, landsId: entry.id,
|
|
680
|
+
...(adopted.id !== subject.id ? { aliasOf: subject.id } : {}),
|
|
681
|
+
receipt,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/** Append an entry to this root's own parent log — what fetch does with what origin sends, and what
|
|
685
|
+
* a served world does with nothing (its pushes land on its branch log). Deduplicated by id. */
|
|
686
|
+
// The parent log's entry ids, per store and path, valid while the file's size and version are the ones
|
|
687
|
+
// recorded (WorldStore.stat changes on any writer's append): a batch of appends dedupes against an index,
|
|
688
|
+
// not by re-parsing the whole log each time.
|
|
689
|
+
const parentIdIndex = new WeakMap();
|
|
690
|
+
export function appendParentEntry(entry, root) {
|
|
691
|
+
const path = parentLogPath(entry.service, root);
|
|
692
|
+
const store = getActiveWorldStore();
|
|
693
|
+
store.mkdir(dirname(path));
|
|
694
|
+
return withAncestryLock(() => store.withLock(`${path}.lock`, () => {
|
|
695
|
+
let indexes = parentIdIndex.get(store);
|
|
696
|
+
if (!indexes) {
|
|
697
|
+
indexes = new Map();
|
|
698
|
+
parentIdIndex.set(store, indexes);
|
|
699
|
+
}
|
|
700
|
+
let index = indexes.get(path);
|
|
701
|
+
if (!index || index.key !== statKey(path)) {
|
|
702
|
+
index = { key: statKey(path), ids: new Set(readRows(path).flatMap((r) => (typeof r.id === 'string' ? [r.id] : []))) };
|
|
703
|
+
indexes.set(path, index);
|
|
704
|
+
}
|
|
705
|
+
if (index.ids.has(entry.id))
|
|
706
|
+
return { appended: false };
|
|
707
|
+
store.append(path, `${JSON.stringify(entry)}\n`);
|
|
708
|
+
index.ids.add(entry.id);
|
|
709
|
+
index.key = statKey(path);
|
|
710
|
+
return { appended: true };
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
/** The whole log of a twin as a served world hands it out: the parent view, then the branch's own
|
|
714
|
+
* entries, one sequence with a position. A clone's parent is exactly this. */
|
|
715
|
+
export function wholeLog(service, root) {
|
|
716
|
+
return [...parentEntries(service, root), ...branchEntries(service, root)];
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* REBASE a branch onto its base's current position (contract: "rebase replays the branch's
|
|
720
|
+
* entries over a newer base, conflicts per subject and field"). A branch is a pointer, so the
|
|
721
|
+
* base moving is invisible until this: the pointer advances to the base's present position, the
|
|
722
|
+
* branch's own entries stay in place and now fold over the moved base, and every entry whose
|
|
723
|
+
* preconditions no longer hold against the new parent tree is named as a conflict — data on the
|
|
724
|
+
* result, never a stop. An unbranched world (no pointer) has nothing to rebase.
|
|
725
|
+
*/
|
|
726
|
+
export function rebaseBranch(service, root, opts = {}) {
|
|
727
|
+
return withAncestryLock(() => {
|
|
728
|
+
const meta = readBranchMeta(service, root);
|
|
729
|
+
if (!meta?.parent)
|
|
730
|
+
return { moved: false, from: 0, to: 0, conflicts: [] };
|
|
731
|
+
if (opts.origin && !meta.origin)
|
|
732
|
+
throw new Error('This local fork has no tracked origin; clone the origin before branching');
|
|
733
|
+
const from = meta.parent.position;
|
|
734
|
+
let tracked = meta.origin && (opts.origin || meta.origin.depth === 0) ? meta.fetchedOrigin ?? { ...meta.origin, depth: 0 } : undefined;
|
|
735
|
+
if (tracked && !opts.origin && !isUrlParent(tracked.at)) {
|
|
736
|
+
const live = captureHistory(service, tracked.at);
|
|
737
|
+
tracked = { ...tracked, directory: live.descriptor.owner.directory, generation: live.descriptor.owner.generation, view: live.view, position: live.position };
|
|
738
|
+
}
|
|
739
|
+
if (tracked && !meta.origin.incomplete && tracked.directory === meta.origin.directory && tracked.view === meta.origin.view && tracked.position === meta.origin.position && tracked.remoteView === meta.origin.remoteView) {
|
|
740
|
+
if (meta.fetchedOrigin)
|
|
741
|
+
writeBranchMeta(service, { ...meta, fetchedOrigin: undefined }, root);
|
|
742
|
+
return { moved: false, from, to: from, conflicts: [] };
|
|
743
|
+
}
|
|
744
|
+
const head = tracked ?? (isUrlParent(meta.parent.at) ? originHead(service, root) : captureHistory(service, meta.parent.at));
|
|
745
|
+
if (!head)
|
|
746
|
+
throw new Error('Origin has no completed immutable history view');
|
|
747
|
+
const directory = isUrlParent(meta.parent.at) ? worldPaths(service, root).dir : worldPaths(service, meta.parent.at).dir;
|
|
748
|
+
const previous = inheritedHistory(service, root);
|
|
749
|
+
const next = tracked ? replaceHistoryOrigin(previous, meta.origin, tracked) : readHistoryView(directory, head.view);
|
|
750
|
+
const nextView = tracked ? saveHistoryView(worldPaths(service, root).dir, next) : head.view;
|
|
751
|
+
const to = historyLength(next.layout);
|
|
752
|
+
const moved = to !== from || nextView !== meta.parent.view;
|
|
753
|
+
// Compare the evaluated old and new views, including removals and reordered layers.
|
|
754
|
+
const before = historyEntries(previous);
|
|
755
|
+
const since = historyChanges(service, previous, next);
|
|
756
|
+
// only a subject that EXISTED at this branch's position can conflict: two branches each minting the
|
|
757
|
+
// same local id for different records is not a disagreement (the vendor mints the real id)
|
|
758
|
+
const existed = new Set(before.filter((e) => e.op === 'set').flatMap((e) => [`${e.subject.type}:${e.subject.id}`, ...(e.aliasOf ? [`${e.subject.type}:${e.aliasOf}`] : [])]));
|
|
759
|
+
// conflicts: a field this branch set that the parent set since, to something else — named by
|
|
760
|
+
// entry, subject and field (contract "Drift and rebase in v2")
|
|
761
|
+
const stable = (v) => JSON.stringify(v) ?? 'undefined';
|
|
762
|
+
// a field both sides set to an INSTANT (updated_at, created) is when each wrote, not what: never a conflict
|
|
763
|
+
const instant = (v) => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(v) && Number.isFinite(Date.parse(v));
|
|
764
|
+
const currentAliases = aliasesFrom(historyEntries(next));
|
|
765
|
+
const conflicts = [];
|
|
766
|
+
for (const e of branchEntries(service, root)) {
|
|
767
|
+
if (e.op !== 'set' || !e.fields || !existed.has(`${e.subject.type}:${e.subject.id}`))
|
|
768
|
+
continue;
|
|
769
|
+
const fields = resolveReferences(service, e.subject.type, e.fields, currentAliases);
|
|
770
|
+
for (const p of since) {
|
|
771
|
+
if (p.op !== 'set' || !p.fields || p.subject.type !== e.subject.type || (p.subject.id !== e.subject.id && p.aliasOf !== e.subject.id))
|
|
772
|
+
continue;
|
|
773
|
+
if (p.fields.deleted === true && fields.deleted !== true)
|
|
774
|
+
conflicts.push({ entryId: e.id, subject: e.subject, field: 'deleted', op: 'exists', expected: true, actual: false });
|
|
775
|
+
for (const [field, value] of Object.entries(p.fields))
|
|
776
|
+
if (field in fields && stable(fields[field]) !== stable(value) && !(instant(value) && instant(fields[field])))
|
|
777
|
+
conflicts.push({ entryId: e.id, subject: e.subject, field, op: 'set', expected: fields[field], actual: value });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (moved) {
|
|
781
|
+
const { viewDirectory: _oldViewDirectory, ...parent } = meta.parent;
|
|
782
|
+
writeBranchMeta(service, { ...meta, fetchedOrigin: undefined, origin: tracked ? { ...tracked, depth: meta.origin.depth } : next.origin, parent: { ...parent, position: to, view: nextView, ...(tracked ? { viewDirectory: canonicalStatePath(worldPaths(service, root).dir) } : {}), ...('remoteView' in head ? { remoteView: head.remoteView } : {}) } }, root);
|
|
783
|
+
dropCheckpoint(service, root);
|
|
784
|
+
}
|
|
785
|
+
return { moved, from, to, conflicts };
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* HISTORY through the kernel: every entry that touched a subject (or every subject of a type), parent
|
|
790
|
+
* log then branch log, in order, with reverted entries dropped and landed copies shown once. A pack
|
|
791
|
+
* that serves a changelog (Jira's), or must never reuse an id it once minted, reads this — never a
|
|
792
|
+
* log file. Each entry's `fields` is what it set; the fold is the reader's.
|
|
793
|
+
*/
|
|
794
|
+
export function subjectHistory(service, subject, root) {
|
|
795
|
+
const parent = parentEntries(service, root);
|
|
796
|
+
const held = landedIds(parent);
|
|
797
|
+
const branch = branchEntries(service, root);
|
|
798
|
+
const reverted = new Set(branch.filter((e) => e.op === 'revert' && e.revertsActionId).map((e) => e.revertsActionId));
|
|
799
|
+
const touches = (e) => e.op === 'set' && e.subject.type === subject.type && (subject.id === undefined || e.subject.id === subject.id || e.aliasOf === subject.id);
|
|
800
|
+
return [...parent.filter((e) => touches(e) && e.fields !== undefined), ...branch.filter((e) => touches(e) && !reverted.has(e.id) && !held.has(e.id))];
|
|
801
|
+
}
|