@volter/world-core 3.0.149 → 3.0.150

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.
@@ -57,7 +57,7 @@ import { worldPaths } from "./storage.js";
57
57
  // the control root that holds it) and never learns what a world is. `volter-world` supplies
58
58
  // discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
59
59
  import { createHash } from 'node:crypto';
60
- import { aliasesFrom, readTreeMap, wholeLog } from "./log.js";
60
+ import { aliasesFrom, readTreeMapsAt, wholeLog } from "./log.js";
61
61
  import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, isTwinBookkeeping } from "./actions.js";
62
62
  import { canonicalJson, diffSubjectFields, hashFieldValue } from "./hash.js";
63
63
  import { applyTwinWrite } from "./serve.js";
@@ -173,11 +173,13 @@ export function describeActionFields(ledgers, actions) {
173
173
  if (!positions.has(e.id))
174
174
  positions.set(e.id, e.batch ? batchStart : i);
175
175
  });
176
- for (const { action, service, stateService } of actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService)) {
177
- const at = positions.get(action.id);
178
- if (at === undefined || !action.fields)
176
+ const mine = actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService && a.action.fields && positions.has(a.action.id));
177
+ // every write's pre-state from one capture of this twin's history (a capture per write read the whole log each time)
178
+ const trees = mine.length ? readTreeMapsAt(ledger.stateService, ledger.controlRoot, mine.map(a => positions.get(a.action.id))) : new Map();
179
+ for (const { action, service, stateService } of mine) {
180
+ if (!action.fields)
179
181
  continue;
180
- const subject = readTreeMap(stateService, ledger.controlRoot, { at }).get(`${action.subject.type}:${action.subject.id}`);
182
+ const subject = trees.get(positions.get(action.id))?.get(`${action.subject.type}:${action.subject.id}`);
181
183
  const fields = subject?.fields ?? {};
182
184
  fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
183
185
  subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
@@ -40,6 +40,9 @@ export declare function validateHistoryOrigin(origin: HistoryOrigin, layout?: Hi
40
40
  export declare const historyEntryTime: (entry: Entry) => number;
41
41
  export declare const historyDigest: (value: unknown) => string;
42
42
  export declare function historyLength(layout: HistoryLayout, depth?: number): number;
43
+ /** The layout of a view's first `at` entries: historyPrefix's, without the segment digests a prefix view carries (a
44
+ * fold reads only the layout, and a partial segment's digest re-reads and hashes its log). */
45
+ export declare function historyLayoutAt(layout: HistoryLayout, at: number): HistoryLayout;
43
46
  export declare function readHistoryView(directory: string, id: string): HistoryView;
44
47
  export declare function historyEntries(view: HistoryView): Entry[];
45
48
  export declare function historyPrefix(view: HistoryView, at: number): HistoryView;
@@ -40,6 +40,13 @@ const viewPath = (directory, id) => {
40
40
  function rows(directory, file) {
41
41
  return getActiveWorldStore().readLines(join(directory, file)).filter(line => line.trim()).map(line => toEntry(parseExactJson(line)));
42
42
  }
43
+ /** The layout of a view's first `at` entries: historyPrefix's, without the segment digests a prefix view carries (a
44
+ * fold reads only the layout, and a partial segment's digest re-reads and hashes its log). */
45
+ export function historyLayoutAt(layout, at) {
46
+ if (!Number.isInteger(at) || at < 0 || at > historyLength(layout))
47
+ throw new Error(`Invalid history position ${at}`);
48
+ return sliceLayout(layout, at);
49
+ }
43
50
  function sliceLayout(layout, at) {
44
51
  const base = layout.base ? historyLength(layout.base) : 0;
45
52
  return { ...(layout.base ? { base: sliceLayout(layout.base, Math.min(at, base)) } : {}), parent: Math.min(Math.max(0, at - base), layout.parent), branch: Math.min(Math.max(0, at - base - layout.parent), layout.branch) };
package/dist/src/log.d.ts CHANGED
@@ -183,6 +183,10 @@ export declare function treeChangesSince(service: string, root: string | undefin
183
183
  removed: string[];
184
184
  appended: string[];
185
185
  } | undefined;
186
+ /** The tree at each of several positions of a twin's history, folded from one capture of it: what
187
+ * `readTreeMap(service, root, { at })` answers for each, without capturing the history again per position (a
188
+ * changeset's field changes read one position per write, and a capture reads the whole log). */
189
+ export declare function readTreeMapsAt(service: string, root: string | undefined, positions: Iterable<number>): Map<number, Tree>;
186
190
  export declare function cutCheckpoint(service: string, root?: string): void;
187
191
  /** The branch's entries the parent does not hold: `set` rows, not reverted, not landed. */
188
192
  export declare function unpushedEntries(service: string, root?: string): Entry[];
package/dist/src/log.js CHANGED
@@ -23,7 +23,7 @@ import { parseExactJson } from "./exact-json.js";
23
23
  // holds. The parent log accepts the v1 observed-event rows too (`toEntry` reads a delta event as
24
24
  // an entry; a non-delta observation folds nothing and is kept for `listEvents`), so a World whose
25
25
  // log was written in that form still reads.
26
- import { captureHistory, historyChanges, historyEntryTime, historyEntries, historyLength, historyPrefix, inheritedHistory, inheritedHistoryKey, originHead, readHistoryView, replaceHistoryOrigin, saveHistoryView, validateHistoryOrigin } from "./history.js";
26
+ import { captureHistory, historyChanges, historyEntryTime, historyEntries, historyLayoutAt, historyLength, historyPrefix, inheritedHistory, inheritedHistoryKey, originHead, readHistoryView, replaceHistoryOrigin, saveHistoryView, validateHistoryOrigin } from "./history.js";
27
27
  import { dirname, join } from 'node:path';
28
28
  import { canonicalStatePath, checkParent, commitParentPin, pinParent, withAncestryLock, withHistoryLock } from "./ancestry.js";
29
29
  import { resolveReferences } from "./references.js";
@@ -569,24 +569,39 @@ export function treeChangesSince(service, root, since) {
569
569
  }
570
570
  return { stamp, changed: treeResources(present), removed, appended: [...order.keys()].filter((k) => tree.has(k)) };
571
571
  }
572
+ /** The tree at each of several positions of a twin's history, folded from one capture of it: what
573
+ * `readTreeMap(service, root, { at })` answers for each, without capturing the history again per position (a
574
+ * changeset's field changes read one position per write, and a capture reads the whole log). */
575
+ export function readTreeMapsAt(service, root, positions) {
576
+ return withHistoryLock(worldPaths(service, root).dir, () => {
577
+ const view = captureHistory(service, root).descriptor;
578
+ const entries = historyEntries(view);
579
+ const trees = new Map();
580
+ for (const at of positions)
581
+ if (!trees.has(at))
582
+ trees.set(at, treeAt(view, entries, at, service));
583
+ return trees;
584
+ });
585
+ }
586
+ /** The tree after a view's first `at` entries; a position inside an observation has none. */
587
+ function treeAt(view, entries, at, service) {
588
+ if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch) {
589
+ let from = at;
590
+ let to = at;
591
+ const batch = entries[at].batch;
592
+ while (from > 0 && entries[from - 1]?.batch === batch)
593
+ from--;
594
+ while (to < entries.length && entries[to]?.batch === batch)
595
+ to++;
596
+ throw new Error(`position ${at} falls inside observation ${batch} — use ${from} or ${to}`);
597
+ }
598
+ return foldHistory(entries.slice(0, at), historyLayoutAt(view.layout, at), service);
599
+ }
572
600
  function readTreeMapFull(service, root, opts = {}) {
573
601
  return withHistoryLock(worldPaths(service, root).dir, () => {
574
602
  if (opts.at !== undefined || opts.view !== undefined) {
575
603
  const view = opts.view ? readHistoryView(worldPaths(service, root).dir, opts.view) : captureHistory(service, root).descriptor;
576
- const at = opts.at ?? historyLength(view.layout);
577
- const entries = historyEntries(view);
578
- if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch) {
579
- let from = at;
580
- let to = at;
581
- const batch = entries[at].batch;
582
- while (from > 0 && entries[from - 1]?.batch === batch)
583
- from--;
584
- while (to < entries.length && entries[to]?.batch === batch)
585
- to++;
586
- throw new Error(`position ${at} falls inside observation ${batch} — use ${from} or ${to}`);
587
- }
588
- const prefix = historyPrefix(view, at);
589
- return foldHistory(entries.slice(0, at), prefix.layout, service);
604
+ return treeAt(view, historyEntries(view), opts.at ?? historyLength(view.layout), service);
590
605
  }
591
606
  // a cold read (no memo) trusts a checkpoint cut at exactly these log facts without re-hashing the logs
592
607
  const facts = opts.until === undefined ? `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}` : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.149",
3
+ "version": "3.0.150",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/changeset.ts CHANGED
@@ -57,7 +57,7 @@ import { worldPaths } from './storage.ts';
57
57
  // the control root that holds it) and never learns what a world is. `volter-world` supplies
58
58
  // discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
59
59
  import { createHash } from 'node:crypto';
60
- import { aliasesFrom, parentEntries, readTreeMap, wholeLog, type Entry } from './log.ts';
60
+ import { aliasesFrom, parentEntries, readTreeMapsAt, wholeLog, type Entry } from './log.ts';
61
61
  import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, isTwinBookkeeping } from './actions.ts';
62
62
  import type { TwinActionRevertSpec } from './actions.ts';
63
63
  import { touchedSubjects } from './actions.ts';
@@ -351,10 +351,12 @@ export function describeActionFields(ledgers: LedgerRef[], actions: ChangesetAct
351
351
  if (!e.batch || e.batch !== entries[i - 1]?.batch) batchStart = i;
352
352
  if (!positions.has(e.id)) positions.set(e.id, e.batch ? batchStart : i);
353
353
  });
354
- for (const { action, service, stateService } of actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService)) {
355
- const at = positions.get(action.id);
356
- if (at === undefined || !action.fields) continue;
357
- const subject = readTreeMap(stateService, ledger.controlRoot, { at }).get(`${action.subject.type}:${action.subject.id}`);
354
+ const mine = actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService && a.action.fields && positions.has(a.action.id));
355
+ // every write's pre-state from one capture of this twin's history (a capture per write read the whole log each time)
356
+ const trees = mine.length ? readTreeMapsAt(ledger.stateService, ledger.controlRoot, mine.map(a => positions.get(a.action.id)!)) : new Map();
357
+ for (const { action, service, stateService } of mine) {
358
+ if (!action.fields) continue;
359
+ const subject = trees.get(positions.get(action.id)!)?.get(`${action.subject.type}:${action.subject.id}`);
358
360
  const fields = subject?.fields ?? {};
359
361
  fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
360
362
  subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
package/src/history.ts CHANGED
@@ -37,6 +37,12 @@ const viewPath = (directory: string, id: string) => {
37
37
  function rows(directory: string, file: Segment['file']): Entry[] {
38
38
  return getActiveWorldStore().readLines(join(directory, file)).filter(line => line.trim()).map(line => toEntry(parseExactJson(line)));
39
39
  }
40
+ /** The layout of a view's first `at` entries: historyPrefix's, without the segment digests a prefix view carries (a
41
+ * fold reads only the layout, and a partial segment's digest re-reads and hashes its log). */
42
+ export function historyLayoutAt(layout: HistoryLayout, at: number): HistoryLayout {
43
+ if (!Number.isInteger(at) || at < 0 || at > historyLength(layout)) throw new Error(`Invalid history position ${at}`);
44
+ return sliceLayout(layout, at);
45
+ }
40
46
  function sliceLayout(layout: HistoryLayout, at: number): HistoryLayout {
41
47
  const base = layout.base ? historyLength(layout.base) : 0;
42
48
  return { ...(layout.base ? { base: sliceLayout(layout.base, Math.min(at, base)) } : {}), parent: Math.min(Math.max(0, at - base), layout.parent), branch: Math.min(Math.max(0, at - base - layout.parent), layout.branch) };
package/src/log.ts CHANGED
@@ -23,7 +23,7 @@ import { parseExactJson } from './exact-json.ts';
23
23
  // holds. The parent log accepts the v1 observed-event rows too (`toEntry` reads a delta event as
24
24
  // an entry; a non-delta observation folds nothing and is kept for `listEvents`), so a World whose
25
25
  // log was written in that form still reads.
26
- import { captureHistory, historyChanges, historyEntryTime, historyEntries, historyLength, historyPrefix, inheritedHistory, inheritedHistoryKey, originHead, readHistoryView, replaceHistoryOrigin, saveHistoryView, validateHistoryOrigin, type HistoryLayout } from './history.ts';
26
+ import { captureHistory, historyChanges, historyEntryTime, historyEntries, historyLayoutAt, historyLength, historyPrefix, inheritedHistory, inheritedHistoryKey, originHead, readHistoryView, replaceHistoryOrigin, saveHistoryView, validateHistoryOrigin, type HistoryLayout } from './history.ts';
27
27
  import { dirname, join } from 'node:path';
28
28
  import { canonicalStatePath, checkParent, commitParentPin, pinParent, withAncestryLock, withHistoryLock } from './ancestry.ts';
29
29
  import { resolveReferences } from './references.ts';
@@ -591,20 +591,35 @@ export function treeChangesSince(service: string, root: string | undefined, sinc
591
591
  return { stamp, changed: treeResources(present), removed, appended: [...order.keys()].filter((k) => tree.has(k)) };
592
592
  }
593
593
 
594
+ /** The tree at each of several positions of a twin's history, folded from one capture of it: what
595
+ * `readTreeMap(service, root, { at })` answers for each, without capturing the history again per position (a
596
+ * changeset's field changes read one position per write, and a capture reads the whole log). */
597
+ export function readTreeMapsAt(service: string, root: string | undefined, positions: Iterable<number>): Map<number, Tree> {
598
+ return withHistoryLock(worldPaths(service, root).dir, () => {
599
+ const view = captureHistory(service, root).descriptor;
600
+ const entries = historyEntries(view);
601
+ const trees = new Map<number, Tree>();
602
+ for (const at of positions) if (!trees.has(at)) trees.set(at, treeAt(view, entries, at, service));
603
+ return trees;
604
+ });
605
+ }
606
+
607
+ /** The tree after a view's first `at` entries; a position inside an observation has none. */
608
+ function treeAt(view: ReturnType<typeof captureHistory>['descriptor'], entries: Entry[], at: number, service: string): Tree {
609
+ if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch) {
610
+ let from = at; let to = at; const batch = entries[at]!.batch;
611
+ while (from > 0 && entries[from - 1]?.batch === batch) from--;
612
+ while (to < entries.length && entries[to]?.batch === batch) to++;
613
+ throw new Error(`position ${at} falls inside observation ${batch} — use ${from} or ${to}`);
614
+ }
615
+ return foldHistory(entries.slice(0, at), historyLayoutAt(view.layout, at), service);
616
+ }
617
+
594
618
  function readTreeMapFull(service: string, root?: string, opts: { until?: string; at?: number; view?: string } = {}): Tree {
595
619
  return withHistoryLock(worldPaths(service, root).dir, () => {
596
620
  if (opts.at !== undefined || opts.view !== undefined) {
597
621
  const view = opts.view ? readHistoryView(worldPaths(service, root).dir, opts.view) : captureHistory(service, root).descriptor;
598
- const at = opts.at ?? historyLength(view.layout);
599
- const entries = historyEntries(view);
600
- if (at > 0 && at < entries.length && entries[at]?.batch && entries[at]?.batch === entries[at - 1]?.batch) {
601
- let from = at; let to = at; const batch = entries[at]!.batch;
602
- while (from > 0 && entries[from - 1]?.batch === batch) from--;
603
- while (to < entries.length && entries[to]?.batch === batch) to++;
604
- throw new Error(`position ${at} falls inside observation ${batch} — use ${from} or ${to}`);
605
- }
606
- const prefix = historyPrefix(view, at);
607
- return foldHistory(entries.slice(0, at), prefix.layout, service);
622
+ return treeAt(view, historyEntries(view), opts.at ?? historyLength(view.layout), service);
608
623
  }
609
624
  // a cold read (no memo) trusts a checkpoint cut at exactly these log facts without re-hashing the logs
610
625
  const facts = opts.until === undefined ? `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}` : null;