@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
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Transaction/action log (scorecard R18) — the semantic correction that separates
|
|
2
|
+
// OBSERVED facts from LOCAL transaction commits.
|
|
3
|
+
//
|
|
4
|
+
// The observed-event log (`events.jsonl`) holds only what was observed upstream
|
|
5
|
+
// (connector pulls) or confirmed after a push. Local simulator/fork writes do NOT
|
|
6
|
+
// go there — they are transaction commits in `actions.jsonl`, projected OVER the
|
|
7
|
+
// observed mirror to produce the twin's current state. Undo is a `revert` commit;
|
|
8
|
+
// a push that succeeds appends a `confirm` commit mapping the local transaction
|
|
9
|
+
// to the observed event it produced, which SUPPRESSES the local projection (the
|
|
10
|
+
// fact is now carried by the observed log, so it must not be double-counted).
|
|
11
|
+
//
|
|
12
|
+
// Projection = observed mirror, then apply each `set` transaction in order,
|
|
13
|
+
// skipping any transaction that was reverted or confirmed.
|
|
14
|
+
import { withAncestryLock } from "./ancestry.js";
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { canonicalJson, subjectKey } from "./hash.js";
|
|
19
|
+
import { appendDurable, eventsLockPath, projectionLockPath, twinLog, withFileLock, worldPaths } from "./storage.js";
|
|
20
|
+
import { landAsPlaceholder, placeholderPullActive } from "./placeholder-remote.js";
|
|
21
|
+
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree } from "./log.js";
|
|
22
|
+
import { getActiveWorldStore } from "./world-store.js";
|
|
23
|
+
export class TwinActionPreconditionError extends Error {
|
|
24
|
+
actionId;
|
|
25
|
+
failed;
|
|
26
|
+
constructor(actionId, failed) {
|
|
27
|
+
super(`Twin transaction precondition failed for ${actionId}: ${failed.subject.type}:${failed.subject.id}.${failed.field} ${failed.op}`);
|
|
28
|
+
this.actionId = actionId;
|
|
29
|
+
this.failed = failed;
|
|
30
|
+
this.name = 'TwinActionPreconditionError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function actionsPath(service, root) {
|
|
34
|
+
return join(dirname(worldPaths(service, root).events), 'actions.jsonl');
|
|
35
|
+
}
|
|
36
|
+
const actionsLock = (service, root) => `${actionsPath(service, root)}.lock`;
|
|
37
|
+
const projectionLock = (service, root) => projectionLockPath(worldPaths(service, root));
|
|
38
|
+
/** Every appended action carries a correlationId — generate one when the caller
|
|
39
|
+
* hasn't supplied it, so downstream joins (push ledger, logs) always have an id
|
|
40
|
+
* to key on (D3). Preserves a caller-supplied id (e.g. propagated from an HTTP
|
|
41
|
+
* request id) so a whole call chain can share one. */
|
|
42
|
+
function withCorrelationId(action) {
|
|
43
|
+
// The wire's request id (the addressed service stamps every response with one and threads it
|
|
44
|
+
// into the handler's async context) is the correlation when the caller supplies none — the
|
|
45
|
+
// join from a request on the wire to the action rows it caused, with no pack involved.
|
|
46
|
+
return action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
|
|
47
|
+
}
|
|
48
|
+
/** Subjects a `set` action touches: its own subject, every projection resource, and every
|
|
49
|
+
* precondition subject — a precondition established the action's validity against that
|
|
50
|
+
* subject's state, so remote movement there is drift for this action too. */
|
|
51
|
+
export function touchedSubjects(action) {
|
|
52
|
+
const out = new Map();
|
|
53
|
+
out.set(subjectKey(action.subject), action.subject);
|
|
54
|
+
for (const r of [
|
|
55
|
+
...(action.projection?.creates ?? []),
|
|
56
|
+
...(action.projection?.updates ?? []),
|
|
57
|
+
...(action.projection?.deletes ?? []),
|
|
58
|
+
])
|
|
59
|
+
out.set(subjectKey(r), { type: r.type, id: r.id });
|
|
60
|
+
for (const p of action.preconditions ?? [])
|
|
61
|
+
out.set(subjectKey(p.subject), p.subject);
|
|
62
|
+
return [...out.values()];
|
|
63
|
+
}
|
|
64
|
+
const idMemos = new WeakMap();
|
|
65
|
+
function logKey(path) { const s = getActiveWorldStore().stat(path); return s ? `${s.size}:${s.mtimeMs}` : '-'; }
|
|
66
|
+
function idMemoSlot(path) {
|
|
67
|
+
const store = getActiveWorldStore();
|
|
68
|
+
let memos = idMemos.get(store);
|
|
69
|
+
if (!memos) {
|
|
70
|
+
memos = new Map();
|
|
71
|
+
idMemos.set(store, memos);
|
|
72
|
+
}
|
|
73
|
+
return memos;
|
|
74
|
+
}
|
|
75
|
+
function actionIds(service, root) {
|
|
76
|
+
const path = actionsPath(service, root);
|
|
77
|
+
const memos = idMemoSlot(path);
|
|
78
|
+
const key = logKey(path);
|
|
79
|
+
const held = memos.get(path);
|
|
80
|
+
if (held && held.key === key)
|
|
81
|
+
return held.ids;
|
|
82
|
+
const ids = new Set(listActions(service, root).map((a) => a.id));
|
|
83
|
+
memos.set(path, { key, ids });
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
function appendActionRaw(action, root) {
|
|
87
|
+
const path = actionsPath(action.service, root);
|
|
88
|
+
getActiveWorldStore().mkdir(dirname(path));
|
|
89
|
+
const memos = idMemoSlot(path);
|
|
90
|
+
const held = memos.get(path);
|
|
91
|
+
const current = held !== undefined && held.key === logKey(path);
|
|
92
|
+
withAncestryLock(() => appendDurable(path, `${JSON.stringify(action)}\n`));
|
|
93
|
+
if (current) {
|
|
94
|
+
held.ids.add(action.id);
|
|
95
|
+
held.key = logKey(path);
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
memos.delete(path);
|
|
99
|
+
twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
|
|
100
|
+
appendObserver?.(action, root);
|
|
101
|
+
}
|
|
102
|
+
/** ONE observer of appended entries (state-system.ts: the runtime's `deploy: auto` hook). Called
|
|
103
|
+
* under the append locks, so it must only schedule work, never do it. */
|
|
104
|
+
let appendObserver;
|
|
105
|
+
export function observeAppends(observer) { appendObserver = observer; }
|
|
106
|
+
/** The placeholder remote (contract section of that name): while a service's placeholder-pull
|
|
107
|
+
* window is open, a `set` write is a pull, not a commit — it lands as observed events and no
|
|
108
|
+
* action row exists. Returns the action the caller hands back (its id names what landed). */
|
|
109
|
+
function landIfPlaceholderPull(action, root) {
|
|
110
|
+
if (action.op !== 'set' || !placeholderPullActive(action.service, root))
|
|
111
|
+
return null;
|
|
112
|
+
const { events } = landAsPlaceholder(action, root);
|
|
113
|
+
twinLog('action.placeholder', { service: action.service, id: action.id, op: action.op, events: events.length });
|
|
114
|
+
return { ...action, id: events.at(-1)?.id ?? action.id };
|
|
115
|
+
}
|
|
116
|
+
function replayBody(action) {
|
|
117
|
+
const { correlationId: _correlationId, ...stampedBody } = action;
|
|
118
|
+
// A confirmation is content-addressed by the observed event ids. Its timestamp is observation
|
|
119
|
+
// metadata, just like the occurredAt/observedAt values that appendEvent ignores when replaying
|
|
120
|
+
// one content-addressed event. Two reconcilers confirming the same bytes at different wall-clock
|
|
121
|
+
// instants must therefore converge on the first durable confirmation rather than conflict.
|
|
122
|
+
// A KEYED write is the same request however long the retry took, so its timestamp is observation
|
|
123
|
+
// metadata too — exactly as a confirmation's is. Comparing it would turn the retry the key was
|
|
124
|
+
// asked for into a hard conflict.
|
|
125
|
+
const body = stampedBody.idempotencyKey !== undefined
|
|
126
|
+
? (({ occurredAt: _occurredAt, ...rest }) => rest)(stampedBody)
|
|
127
|
+
: stampedBody;
|
|
128
|
+
return canonicalJson(body);
|
|
129
|
+
}
|
|
130
|
+
function existingExactAction(action, root) {
|
|
131
|
+
const existing = listActions(action.service, root).find((candidate) => candidate.id === action.id);
|
|
132
|
+
if (!existing)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (replayBody(existing) !== replayBody(action)) {
|
|
135
|
+
throw new Error(`Conflicting duplicate twin action: ${action.service}/${action.id}`);
|
|
136
|
+
}
|
|
137
|
+
return existing;
|
|
138
|
+
}
|
|
139
|
+
/** The request-scoped correlation id (D3): set by the kernel fetch adapter for the duration of one
|
|
140
|
+
* handler call from the wire's `x-twins-request-id`; read by appendAction as the default. */
|
|
141
|
+
// created on first use, never at import: a browser bundle of a mirror client carries this module and has
|
|
142
|
+
// no AsyncLocalStorage (see serve.ts identitySlot)
|
|
143
|
+
let correlationStore;
|
|
144
|
+
const correlationScope = () => (correlationStore ??= new AsyncLocalStorage());
|
|
145
|
+
export function runWithCorrelationId(id, fn) { return correlationScope().run(id, fn); }
|
|
146
|
+
export function currentCorrelationId() { return correlationScope().getStore(); }
|
|
147
|
+
export function appendAction(action, root) {
|
|
148
|
+
// Evaluate and append under the service projection and actions locks. A precondition is a
|
|
149
|
+
// compare-and-set, not an advisory validation: checking outside either lock would let another
|
|
150
|
+
// writer invalidate it before this action lands. The shadow basis is stamped under the same
|
|
151
|
+
// lock so the recorded merge base is the mirror the preconditions were checked against.
|
|
152
|
+
return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
|
|
153
|
+
const stamped = withCorrelationId(action);
|
|
154
|
+
assertPreconditions(stamped, root);
|
|
155
|
+
const placeholder = landIfPlaceholderPull(stamped, root);
|
|
156
|
+
if (placeholder)
|
|
157
|
+
return placeholder;
|
|
158
|
+
appendActionRaw(stamped, root);
|
|
159
|
+
return stamped;
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
/** Append `action` only if no exact action with the same id already exists — the whole
|
|
163
|
+
* replay/precondition/append decision runs under the service projection + actions locks, so it's
|
|
164
|
+
* atomic across processes (two concurrent identical writes converge to ONE action; distinct
|
|
165
|
+
* writes both land). A reused id with different content fails loudly. */
|
|
166
|
+
export function appendActionIfAbsent(action, root) {
|
|
167
|
+
return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
|
|
168
|
+
const identified = withCorrelationId(action);
|
|
169
|
+
// An exact retry is already committed. Resolve it before re-evaluating author-time
|
|
170
|
+
// preconditions against the state that first commit intentionally changed — and
|
|
171
|
+
// before paying the basis stamp's event-log read (the replayed path stays cheap).
|
|
172
|
+
const existing = existingExactAction(identified, root);
|
|
173
|
+
if (existing)
|
|
174
|
+
return { action: existing, appended: false };
|
|
175
|
+
const stamped = identified;
|
|
176
|
+
assertPreconditions(stamped, root);
|
|
177
|
+
const placeholder = landIfPlaceholderPull(stamped, root);
|
|
178
|
+
if (placeholder)
|
|
179
|
+
return { action: placeholder, appended: true, placeholder: true };
|
|
180
|
+
appendActionRaw(stamped, root);
|
|
181
|
+
return { action: stamped, appended: true };
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Append `build(n)` as an OCCURRENCE: the nth time this exact write has been made.
|
|
186
|
+
*
|
|
187
|
+
* A local vendor write is an occurrence, not a replay — two calls are two actions, even
|
|
188
|
+
* byte-identical in the same instant (docs/contributing/adding-a-twin.md#5-build-on-the-shared-kernel--dont-reinvent,
|
|
189
|
+
* "A local write is an occurrence"). `appendActionIfAbsent` cannot express that: its identity is content, and
|
|
190
|
+
* content provably cannot separate "the same request delivered twice" from "the same change made
|
|
191
|
+
* twice". So a caller wanting at-most-once passes an explicit key and uses that function; a caller
|
|
192
|
+
* recording what actually happened uses this one.
|
|
193
|
+
*
|
|
194
|
+
* The ordinal keeps every EXISTING action id byte-identical: occurrence 0 is `build(0)`'s own id,
|
|
195
|
+
* and only a genuine repeat becomes `<id>#1`, `#2`. It is derived under the same projection +
|
|
196
|
+
* actions locks as the append, so two processes racing take different ordinals rather than
|
|
197
|
+
* colliding, and it is deterministic — replaying one sequence of writes onto a fresh root yields
|
|
198
|
+
* the same ordinals, which is what serve-path determinism requires.
|
|
199
|
+
*/
|
|
200
|
+
export function appendActionOccurrence(base, root) {
|
|
201
|
+
return withFileLock(projectionLock(base.service, root), () => withFileLock(actionsLock(base.service, root), () => {
|
|
202
|
+
const stamped = occurrenceOf(base, actionIds(base.service, root), root);
|
|
203
|
+
assertPreconditions(stamped, root);
|
|
204
|
+
const placeholder = landIfPlaceholderPull(stamped, root);
|
|
205
|
+
if (placeholder)
|
|
206
|
+
return { action: placeholder, appended: true, placeholder: true };
|
|
207
|
+
appendActionRaw(stamped, root);
|
|
208
|
+
return { action: stamped, appended: true };
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/** The occurrence rule, shared by both appenders that use it — the ordinal AND the merge base.
|
|
212
|
+
* Pure over (base, the log as it stands): the caller holds the lock. */
|
|
213
|
+
function occurrenceOf(base, taken, root) {
|
|
214
|
+
{
|
|
215
|
+
let ordinal = 0;
|
|
216
|
+
while (taken.has(occurrenceId(base.id, ordinal)))
|
|
217
|
+
ordinal += 1;
|
|
218
|
+
// an occurrence past the first is its own entry: the tree it changes is read at the head, and a
|
|
219
|
+
// push onto a moved parent is refused by position, not by a basis stamped here (v2)
|
|
220
|
+
return withCorrelationId({ ...base, id: occurrenceId(base.id, ordinal) });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Occurrence 0 IS the base id — so nothing that exists today changes shape. The appender owns
|
|
224
|
+
* this math; a caller passing an already-ordinalled id would stack them (`…#1#1`). */
|
|
225
|
+
export function occurrenceId(base, ordinal) {
|
|
226
|
+
return ordinal === 0 ? base : `${base}#${ordinal}`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Evaluate current projected state and optionally append one action under ONE service projection
|
|
230
|
+
* transaction (the shared projection lock plus the action-log lock). This is the narrow
|
|
231
|
+
* state-dependent seam for vendor operations whose acceptance and resulting fields depend on the
|
|
232
|
+
* latest observed + local projection (for example, immutable version publish).
|
|
233
|
+
*
|
|
234
|
+
* The callback must remain synchronous and side-effect free: it computes a decision from the
|
|
235
|
+
* supplied snapshot. Durable state changes only through the returned action, which this helper
|
|
236
|
+
* appends before releasing the lock.
|
|
237
|
+
*/
|
|
238
|
+
export function decideAndAppendAction(service, decide, root) {
|
|
239
|
+
return withFileLock(projectionLock(service, root), () => withFileLock(actionsLock(service, root), () => {
|
|
240
|
+
const resources = projectResources(service, root);
|
|
241
|
+
const decision = decide(resources);
|
|
242
|
+
if (decision.kind === 'skip')
|
|
243
|
+
return { value: decision.value, appended: false };
|
|
244
|
+
if (decision.action.service !== service) {
|
|
245
|
+
throw new Error(`Atomic action service mismatch: expected ${service}, got ${decision.action.service}`);
|
|
246
|
+
}
|
|
247
|
+
const identified = withCorrelationId(decision.action);
|
|
248
|
+
// This seam was left on content-dedupe when the occurrence ruling first landed, and 21 packs
|
|
249
|
+
// write through it — so they kept losing a write that returned a subject to a value it held
|
|
250
|
+
// one instant earlier, which is the whole defect.
|
|
251
|
+
if (decision.identity === 'caller') {
|
|
252
|
+
const existing = existingExactAction(identified, root);
|
|
253
|
+
if (existing)
|
|
254
|
+
return { value: decision.value, action: existing, appended: false };
|
|
255
|
+
const stamped = identified;
|
|
256
|
+
assertPreconditionsAgainst(stamped, resources);
|
|
257
|
+
const placeholder = landIfPlaceholderPull(stamped, root);
|
|
258
|
+
if (placeholder)
|
|
259
|
+
return { value: decision.value, action: placeholder, appended: true, placeholder: true };
|
|
260
|
+
appendActionRaw(stamped, root);
|
|
261
|
+
return { value: decision.value, action: stamped, appended: true };
|
|
262
|
+
}
|
|
263
|
+
const stamped = occurrenceOf(decision.action, actionIds(service, root), root);
|
|
264
|
+
assertPreconditionsAgainst(stamped, resources);
|
|
265
|
+
const placeholder = landIfPlaceholderPull(stamped, root);
|
|
266
|
+
if (placeholder)
|
|
267
|
+
return { value: decision.value, action: placeholder, appended: true, placeholder: true };
|
|
268
|
+
appendActionRaw(stamped, root);
|
|
269
|
+
return { value: decision.value, action: stamped, appended: true };
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
export function appendTransactionCommit(commit, root) {
|
|
273
|
+
return appendAction(commit, root);
|
|
274
|
+
}
|
|
275
|
+
export function listActions(service, root) {
|
|
276
|
+
const path = actionsPath(service, root);
|
|
277
|
+
return getActiveWorldStore()
|
|
278
|
+
.readLines(path)
|
|
279
|
+
.filter((line) => line.trim())
|
|
280
|
+
.map((line) => JSON.parse(line));
|
|
281
|
+
}
|
|
282
|
+
const META = new Set(['id', 'type', 'updatedAt']);
|
|
283
|
+
function projectedField(resource, field) {
|
|
284
|
+
if (!resource)
|
|
285
|
+
return undefined;
|
|
286
|
+
if (field === 'id' || field === 'type' || field === 'updatedAt')
|
|
287
|
+
return resource[field];
|
|
288
|
+
return resource[field];
|
|
289
|
+
}
|
|
290
|
+
/** The kernel's ONE deterministic check: does `actual` satisfy the precondition expression?
|
|
291
|
+
* Shared by write-time preconditions (here), plan conflict detection (plan.ts) and changeset
|
|
292
|
+
* verifiers (changeset.ts) — one evaluator, so a check means the same thing everywhere. */
|
|
293
|
+
export function checkPrecondition(precondition, actual) {
|
|
294
|
+
switch (precondition.op) {
|
|
295
|
+
case 'exists': return actual !== undefined;
|
|
296
|
+
case 'not_exists': return actual === undefined;
|
|
297
|
+
case 'eq':
|
|
298
|
+
case 'version_eq': return Object.is(actual, precondition.value);
|
|
299
|
+
case 'neq': return !Object.is(actual, precondition.value);
|
|
300
|
+
default: return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Look a precondition subject's field up in projected resources (undefined = no resource or
|
|
304
|
+
* no field — exactly what `exists`/`not_exists` distinguish). */
|
|
305
|
+
export function projectedPreconditionValue(precondition, resources) {
|
|
306
|
+
const resource = resources.find((r) => r.type === precondition.subject.type && r.id === precondition.subject.id);
|
|
307
|
+
return projectedField(resource, precondition.field);
|
|
308
|
+
}
|
|
309
|
+
function assertPreconditionsAgainst(action, resources) {
|
|
310
|
+
if (!action.preconditions?.length)
|
|
311
|
+
return;
|
|
312
|
+
for (const precondition of action.preconditions) {
|
|
313
|
+
const actual = projectedPreconditionValue(precondition, resources);
|
|
314
|
+
if (!checkPrecondition(precondition, actual))
|
|
315
|
+
throw new TwinActionPreconditionError(action.id, precondition);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function assertPreconditions(action, root) {
|
|
319
|
+
// Most writes carry none: projecting (and cloning) the whole tree to check nothing cost every append O(tree).
|
|
320
|
+
if (!action.preconditions?.length)
|
|
321
|
+
return;
|
|
322
|
+
assertPreconditionsAgainst(action, projectResources(action.service, root));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Project the action log over the observed mirror → current twin resources.
|
|
326
|
+
* `set` actions overlay fields (creating subjects that don't exist in the mirror);
|
|
327
|
+
* reverted and confirmed actions are skipped (confirmed facts come from the
|
|
328
|
+
* observed log instead, so they are not projected twice).
|
|
329
|
+
*/
|
|
330
|
+
/** THE TREE (log.ts): what a read sees — the nearest checkpoint plus the entries since; the parent's
|
|
331
|
+
* entries, then this branch's, skipping any the parent already holds. `until` folds only the
|
|
332
|
+
* branch entries BEFORE that id — the state a write saw at its own append (the rebase's
|
|
333
|
+
* evaluation point). */
|
|
334
|
+
export function projectResources(service, root, opts = {}) {
|
|
335
|
+
return readTree(service, root, opts);
|
|
336
|
+
}
|
|
337
|
+
/** The local → vendor id aliases the landed copies carry: a read by the id a caller was handed before
|
|
338
|
+
* its write was performed resolves to the row the vendor now owns. */
|
|
339
|
+
export function subjectAliases(service, root) {
|
|
340
|
+
const out = new Map();
|
|
341
|
+
for (const [from, to] of aliasesFrom(parentEntries(service, root)))
|
|
342
|
+
out.set(from, to);
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
/** Resolve a subject id through the aliases: the vendor's id when a confirm rebound it, else the id itself. */
|
|
346
|
+
export function resolveSubjectId(service, type, id, root) {
|
|
347
|
+
return subjectAliases(service, root).get(`${type}:${id}`) ?? id;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Confirm a local action after it was pushed to the real vendor (R18): record the
|
|
351
|
+
* confirmed fields as an OBSERVED event (origin 'external' — it's now real) and
|
|
352
|
+
* append a `confirm` action mapping the local action → that observed event id.
|
|
353
|
+
* Projection then drops the local action (the fact lives in the observed log), so
|
|
354
|
+
* the change is counted exactly once. Returns the observed event id.
|
|
355
|
+
*/
|
|
356
|
+
export function confirmAction(opts) {
|
|
357
|
+
// LANDING (log.ts): the entry is copied to the parent log with its receipt — no confirm row, no
|
|
358
|
+
// suppression; the fold skips a branch entry the parent holds.
|
|
359
|
+
const paths = worldPaths(opts.service, opts.root);
|
|
360
|
+
const base = listActions(opts.service, opts.root).find((a) => a.id === opts.actionId)
|
|
361
|
+
?? { id: opts.actionId, service: opts.service, op: 'set', subject: opts.subject, occurredAt: opts.occurredAt };
|
|
362
|
+
const receipt = { status: 'deployed', at: opts.occurredAt, ...(opts.vendorSubjectId ? { externalId: opts.vendorSubjectId } : {}), ...(opts.receipt ?? {}) };
|
|
363
|
+
const copies = [
|
|
364
|
+
...(opts.additionalObservations ?? []).map((o) => landedCopy(base, receipt, { subject: o.subject, fields: o.fields })),
|
|
365
|
+
landedCopy(base, receipt, { subject: opts.subject, fields: opts.fields, ...(opts.vendorSubjectId ? { vendorSubjectId: opts.vendorSubjectId } : {}) }),
|
|
366
|
+
];
|
|
367
|
+
withFileLock(projectionLock(opts.service, opts.root), () => {
|
|
368
|
+
withAncestryLock(() => withFileLock(eventsLockPath(paths), () => {
|
|
369
|
+
const held = landedIds(parentEntries(opts.service, opts.root));
|
|
370
|
+
for (const copy of copies)
|
|
371
|
+
if (!held.has(copy.id))
|
|
372
|
+
appendDurable(paths.events, `${JSON.stringify(copy)}\n`);
|
|
373
|
+
}));
|
|
374
|
+
dropCheckpoint(opts.service, opts.root);
|
|
375
|
+
});
|
|
376
|
+
const observedEventIds = copies.map((c) => c.id);
|
|
377
|
+
return { observedEventId: observedEventIds.at(-1), observedEventIds };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Revert one pending `set` action — decision AND append under the service projection +
|
|
381
|
+
* actions locks, so a concurrent writer (a connector confirming the same action from
|
|
382
|
+
* another process) cannot land between the check and the revert: the whole read-decide-
|
|
383
|
+
* append is ONE critical section. Idempotent: a repeat answers 'already-reverted'.
|
|
384
|
+
* Only `set` rows are revertable — reverting a revert or a confirm is a category error.
|
|
385
|
+
*/
|
|
386
|
+
export function revertAction(opts) {
|
|
387
|
+
return withFileLock(projectionLock(opts.service, opts.root), () => withFileLock(actionsLock(opts.service, opts.root), () => {
|
|
388
|
+
const all = listActions(opts.service, opts.root);
|
|
389
|
+
const target = all.find((a) => a.id === opts.actionId);
|
|
390
|
+
if (target === undefined)
|
|
391
|
+
return { status: 'not-found' };
|
|
392
|
+
if (target.op !== 'set')
|
|
393
|
+
return { status: 'not-revertable', op: target.op };
|
|
394
|
+
// landed and taken by the vendor: not revertable; a landed copy the vendor refused or failed is
|
|
395
|
+
const landedCopy = parentEntries(opts.service, opts.root).find((e) => e.id === opts.actionId || e.landsId === opts.actionId);
|
|
396
|
+
if (landedCopy && landedCopy.receipt?.status !== 'refused' && landedCopy.receipt?.status !== 'failed')
|
|
397
|
+
return { status: 'confirmed' };
|
|
398
|
+
if (all.some((a) => a.op === 'revert' && a.revertsActionId === opts.actionId))
|
|
399
|
+
return { status: 'already-reverted' };
|
|
400
|
+
const revert = withCorrelationId({
|
|
401
|
+
id: `revert:${opts.actionId}`,
|
|
402
|
+
service: opts.service,
|
|
403
|
+
op: 'revert',
|
|
404
|
+
subject: target.subject,
|
|
405
|
+
occurredAt: opts.occurredAt,
|
|
406
|
+
revertsActionId: opts.actionId,
|
|
407
|
+
});
|
|
408
|
+
appendActionRaw(revert, opts.root);
|
|
409
|
+
return { status: 'reverted', revertId: revert.id };
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
/** The LOCAL OVERLAY: pending actions (set, not reverted, not yet confirmed) — the divergence
|
|
413
|
+
* from the mirror, what a twin's projection folds over the observed state. A QUARANTINED row
|
|
414
|
+
* (contract "A snapshot import never plants a push") is still local state and stays here; it
|
|
415
|
+
* leaves only the PUSHABLE suffix below. */
|
|
416
|
+
export function pendingActions(service, root) {
|
|
417
|
+
const actions = listActions(service, root);
|
|
418
|
+
const reverted = new Set(actions.filter((a) => a.op === 'revert').map((a) => a.revertsActionId));
|
|
419
|
+
const landed = landedIds(parentEntries(service, root));
|
|
420
|
+
return actions.filter((a) => a.op === 'set' && !reverted.has(a.id) && !landed.has(a.id));
|
|
421
|
+
}
|
|
422
|
+
/** A twin's OWN bookkeeping in its action log — a subject type beginning with `_` (stripe's
|
|
423
|
+
* `_idempotency` store, for one): part of the overlay the twin serves from, never a change the
|
|
424
|
+
* app made. It is not in the log a user reads, not in a diff, not in a changeset, not pushed. */
|
|
425
|
+
export function isTwinBookkeeping(action) {
|
|
426
|
+
return action.subject.type.startsWith('_');
|
|
427
|
+
}
|
|
428
|
+
/** The PUSHABLE suffix — `log origin..HEAD` as the push arm, the pending door, plans and the
|
|
429
|
+
* unpushed count read it: the overlay minus quarantined rows (an import is a copy, not a
|
|
430
|
+
* decision; releasing a quarantined row is an explicit act, never a scheduler's) and minus the
|
|
431
|
+
* twin's own bookkeeping. */
|
|
432
|
+
export function pushablePendingActions(service, root) {
|
|
433
|
+
return pendingActions(service, root).filter((a) => a.quarantined === undefined && !isTwinBookkeeping(a));
|
|
434
|
+
}
|
|
435
|
+
export const listTransactionCommits = listActions;
|
|
436
|
+
export const pendingTransactionCommits = pendingActions;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const canonicalStatePath: (path: string) => string;
|
|
2
|
+
/** Synchronous only; use exclusive acquisition without age-stealing a live owner.
|
|
3
|
+
* The coordinator lives outside service, instance and hosted World deletion trees. */
|
|
4
|
+
export declare function withAncestryLock<T>(fn: () => T): T;
|
|
5
|
+
/** Create an identity only for an existing parent. A recreated path gets a new identity. */
|
|
6
|
+
export declare function stateGeneration(dir: string): string;
|
|
7
|
+
/** Must be called in the same critical section as child pointer publication. A crash before
|
|
8
|
+
* publication leaves a pending pin; it can be ignored only once its writer is provably dead. */
|
|
9
|
+
export declare function pinParent(parentDir: string, child: string, expected?: string): string;
|
|
10
|
+
/** Mark publication complete only after branch.json exists. */
|
|
11
|
+
export declare function commitParentPin(parentDir: string, child: string): void;
|
|
12
|
+
/** Read-only identity validation. */
|
|
13
|
+
export declare function checkParent(parentDir: string, expected?: string): void;
|
|
14
|
+
/** Caller holds the coordinator through the actual deletion. A committed pin with missing
|
|
15
|
+
* child metadata is uncertainty, not permission: only explicit child removal releases it. */
|
|
16
|
+
export declare function assertStateRemovable(path: string): void;
|
|
17
|
+
export declare function assertNotBeingRemoved(path: string): void;
|
|
18
|
+
/** force on scrub bypasses shape checking only, never another branch's ownership. The coordinator is held to decide
|
|
19
|
+
* and record the removal (`check`, the caller's own precondition, runs there too) and to release it, not through the
|
|
20
|
+
* deletion: every World on the machine shares the coordinator, and a large tree's deletion outlasts their writes'
|
|
21
|
+
* patience. The receipt, written first, keeps the path from being pinned or written while it is deleted. */
|
|
22
|
+
export declare function withStateRemoval<T>(path: string, remove: () => T, check?: () => void): T;
|