@volter/world-core 3.0.148 → 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;
@@ -8,6 +8,13 @@ export declare function removeResourceBlob(service: string, key: string, root?:
8
8
  /** A service's resources dir at this branch, then at each retained ancestor's: where a read resolves a payload, and
9
9
  * what a listing that mints ids covers, so a branch serves bytes its parent received and never re-mints a parent's id. */
10
10
  export declare function resourceChain(service: string, root?: string): string[];
11
+ /** Where a resource blob's key is kept under a resources directory. A key is a relative path, its parts joined by '/'.
12
+ * On Windows a part may hold a character no file name can: a ':' there names an NTFS stream of the file before it, so
13
+ * `asset:<id>` wrote into a stream of a file named `asset` and the store's rename failed (EINVAL). There each of
14
+ * `<>:"|?*`, a control character and '%' itself is written as %XX, one name per key; elsewhere the key is the path. */
15
+ export declare function resourceBlobFile(dir: string, key: string): string;
16
+ /** The key a file under a resources directory holds: resourceBlobFile's inverse, its parts joined by '/'. */
17
+ export declare function resourceBlobKey(dir: string, path: string): string;
11
18
  /** Size of a resource payload at this branch or its ancestors, or null. */
12
19
  export declare function resourceBlobSize(service: string, key: string, root?: string): Promise<number | null>;
13
20
  /** A byte range of a resource payload at this branch or its ancestors (a ranged read where the
@@ -1,4 +1,4 @@
1
- import { isAbsolute, join } from 'node:path';
1
+ import { isAbsolute, join, relative } from 'node:path';
2
2
  import { getActiveBlobStore, readBlobRange } from "./blob-store.js";
3
3
  import { readBranchMeta } from "./log.js";
4
4
  import { worldPaths } from "./storage.js";
@@ -6,7 +6,7 @@ import { worldPaths } from "./storage.js";
6
6
  export async function readResourceBlob(service, key, root) {
7
7
  assertResourceKey(key);
8
8
  for (const dir of resourceChain(service, root)) {
9
- const bytes = await getActiveBlobStore().get(join(dir, key));
9
+ const bytes = await getActiveBlobStore().get(resourceBlobFile(dir, key));
10
10
  if (bytes !== null)
11
11
  return bytes;
12
12
  }
@@ -15,13 +15,13 @@ export async function readResourceBlob(service, key, root) {
15
15
  /** Write a resource payload on this branch (a stored object's bytes), where a read finds it first. */
16
16
  export async function writeResourceBlob(service, key, bytes, root) {
17
17
  assertResourceKey(key);
18
- await getActiveBlobStore().put(join(worldPaths(service, root).resources, key), bytes);
18
+ await getActiveBlobStore().put(resourceBlobFile(worldPaths(service, root).resources, key), bytes);
19
19
  }
20
20
  /** Remove this branch's copy of a resource payload; an ancestor's stays its own (the row that named it is what a
21
21
  * deletion ends). */
22
22
  export async function removeResourceBlob(service, key, root) {
23
23
  assertResourceKey(key);
24
- await getActiveBlobStore().remove(join(worldPaths(service, root).resources, key));
24
+ await getActiveBlobStore().remove(resourceBlobFile(worldPaths(service, root).resources, key));
25
25
  }
26
26
  /** A service's resources dir at this branch, then at each retained ancestor's: where a read resolves a payload, and
27
27
  * what a listing that mints ids covers, so a branch serves bytes its parent received and never re-mints a parent's id. */
@@ -40,6 +40,18 @@ export function resourceChain(service, root) {
40
40
  current = parent.at;
41
41
  }
42
42
  }
43
+ /** Where a resource blob's key is kept under a resources directory. A key is a relative path, its parts joined by '/'.
44
+ * On Windows a part may hold a character no file name can: a ':' there names an NTFS stream of the file before it, so
45
+ * `asset:<id>` wrote into a stream of a file named `asset` and the store's rename failed (EINVAL). There each of
46
+ * `<>:"|?*`, a control character and '%' itself is written as %XX, one name per key; elsewhere the key is the path. */
47
+ export function resourceBlobFile(dir, key) {
48
+ return join(dir, process.platform === 'win32' ? key.replace(/[%<>:"|?*\x00-\x1f]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`) : key);
49
+ }
50
+ /** The key a file under a resources directory holds: resourceBlobFile's inverse, its parts joined by '/'. */
51
+ export function resourceBlobKey(dir, path) {
52
+ const key = relative(dir, path).split('\\').join('/');
53
+ return process.platform === 'win32' ? key.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) : key;
54
+ }
43
55
  function assertResourceKey(key) {
44
56
  if (isAbsolute(key) || key.includes('\0') || key.split(/[\\/]/).includes('..'))
45
57
  throw new Error('Invalid relative resource blob key');
@@ -48,7 +60,7 @@ function assertResourceKey(key) {
48
60
  async function resolveResourceBlob(service, key, root) {
49
61
  assertResourceKey(key);
50
62
  for (const dir of resourceChain(service, root)) {
51
- const path = join(dir, key);
63
+ const path = resourceBlobFile(dir, key);
52
64
  if (await getActiveBlobStore().exists(path))
53
65
  return path;
54
66
  }
@@ -78,7 +78,7 @@ export { corsHeaders, withCors, type CorsDecl } from './cors.js';
78
78
  export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.js';
79
79
  export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.js';
80
80
  export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.js';
81
- export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.js';
81
+ export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.js';
82
82
  export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, withHistoryLock, withHistoryLocks, withStateLock, withStateRemoval } from './ancestry.js';
83
83
  export { captureHistory, captureParentHistory, historyChanges, historyAtInstant, historyDigest, historyEntries, historyLength, historyPrefix, inheritedHistory, originHead, publishOriginHistory, readHistoryView } from './history.js';
84
84
  export type { HistoryView, HistoryLayout, HistoryReference, HistoryOrigin } from './history.js';
@@ -98,7 +98,7 @@ export { multipartBoundary, multipartParts, multipartEnvelope, inferMultipartBou
98
98
  export { corsHeaders, withCors } from "./cors.js";
99
99
  export { setInProcessTwins, vendorFetch, VendorUnreachableError } from "./vendor-call.js";
100
100
  export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver } from "./managed-database.js";
101
- export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, resourceChain, writeResourceBlob } from "./resource-blob.js";
101
+ export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob } from "./resource-blob.js";
102
102
  export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, withHistoryLock, withHistoryLocks, withStateLock, withStateRemoval } from "./ancestry.js";
103
103
  export { captureHistory, captureParentHistory, historyChanges, historyAtInstant, historyDigest, historyEntries, historyLength, historyPrefix, inheritedHistory, originHead, publishOriginHistory, readHistoryView } from "./history.js";
104
104
  export { foldHistory } from "./log.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.148",
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;
@@ -1,4 +1,4 @@
1
- import { isAbsolute, join } from 'node:path';
1
+ import { isAbsolute, join, relative } from 'node:path';
2
2
  import { getActiveBlobStore, readBlobRange } from './blob-store.ts';
3
3
  import { readBranchMeta } from './log.ts';
4
4
  import { worldPaths } from './storage.ts';
@@ -7,7 +7,7 @@ import { worldPaths } from './storage.ts';
7
7
  export async function readResourceBlob(service: string, key: string, root?: string): Promise<Uint8Array | null> {
8
8
  assertResourceKey(key);
9
9
  for (const dir of resourceChain(service, root)) {
10
- const bytes = await getActiveBlobStore().get(join(dir, key));
10
+ const bytes = await getActiveBlobStore().get(resourceBlobFile(dir, key));
11
11
  if (bytes !== null) return bytes;
12
12
  }
13
13
  return null;
@@ -16,14 +16,14 @@ export async function readResourceBlob(service: string, key: string, root?: stri
16
16
  /** Write a resource payload on this branch (a stored object's bytes), where a read finds it first. */
17
17
  export async function writeResourceBlob(service: string, key: string, bytes: Uint8Array, root?: string): Promise<void> {
18
18
  assertResourceKey(key);
19
- await getActiveBlobStore().put(join(worldPaths(service, root).resources, key), bytes);
19
+ await getActiveBlobStore().put(resourceBlobFile(worldPaths(service, root).resources, key), bytes);
20
20
  }
21
21
 
22
22
  /** Remove this branch's copy of a resource payload; an ancestor's stays its own (the row that named it is what a
23
23
  * deletion ends). */
24
24
  export async function removeResourceBlob(service: string, key: string, root?: string): Promise<void> {
25
25
  assertResourceKey(key);
26
- await getActiveBlobStore().remove(join(worldPaths(service, root).resources, key));
26
+ await getActiveBlobStore().remove(resourceBlobFile(worldPaths(service, root).resources, key));
27
27
  }
28
28
 
29
29
  /** A service's resources dir at this branch, then at each retained ancestor's: where a read resolves a payload, and
@@ -42,6 +42,20 @@ export function resourceChain(service: string, root?: string): string[] {
42
42
  }
43
43
  }
44
44
 
45
+ /** Where a resource blob's key is kept under a resources directory. A key is a relative path, its parts joined by '/'.
46
+ * On Windows a part may hold a character no file name can: a ':' there names an NTFS stream of the file before it, so
47
+ * `asset:<id>` wrote into a stream of a file named `asset` and the store's rename failed (EINVAL). There each of
48
+ * `<>:"|?*`, a control character and '%' itself is written as %XX, one name per key; elsewhere the key is the path. */
49
+ export function resourceBlobFile(dir: string, key: string): string {
50
+ return join(dir, process.platform === 'win32' ? key.replace(/[%<>:"|?*\x00-\x1f]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`) : key);
51
+ }
52
+
53
+ /** The key a file under a resources directory holds: resourceBlobFile's inverse, its parts joined by '/'. */
54
+ export function resourceBlobKey(dir: string, path: string): string {
55
+ const key = relative(dir, path).split('\\').join('/');
56
+ return process.platform === 'win32' ? key.replace(/%([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))) : key;
57
+ }
58
+
45
59
  function assertResourceKey(key: string): void {
46
60
  if (isAbsolute(key) || key.includes('\0') || key.split(/[\\/]/).includes('..')) throw new Error('Invalid relative resource blob key');
47
61
  }
@@ -50,7 +64,7 @@ function assertResourceKey(key: string): void {
50
64
  async function resolveResourceBlob(service: string, key: string, root?: string): Promise<string | null> {
51
65
  assertResourceKey(key);
52
66
  for (const dir of resourceChain(service, root)) {
53
- const path = join(dir, key);
67
+ const path = resourceBlobFile(dir, key);
54
68
  if (await getActiveBlobStore().exists(path)) return path;
55
69
  }
56
70
  return null;
package/src/runtime.ts CHANGED
@@ -335,7 +335,7 @@ export { corsHeaders, withCors, type CorsDecl } from './cors.ts';
335
335
  export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.ts';
336
336
  export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.ts';
337
337
  export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
338
- export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.ts';
338
+ export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.ts';
339
339
 
340
340
  export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, withHistoryLock, withHistoryLocks, withStateLock, withStateRemoval } from './ancestry.ts';
341
341