@volter/world-runtime 2.0.40 → 2.0.41

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.
@@ -84,10 +84,12 @@ export async function branchWorld(base, name, options = {}) {
84
84
  }
85
85
  const forked = {};
86
86
  try {
87
- if (from.origin) {
87
+ // the branch's origin is its base's, or none: never one a stopped World of the same name left
88
+ if (from.origin)
88
89
  instance.origin = { ...from.origin };
89
- saveWorldInstance(instance);
90
- }
90
+ else
91
+ delete instance.origin;
92
+ saveWorldInstance(instance);
91
93
  for (const { service, fromRoot, states } of source) {
92
94
  const toRoot = join(instance.dirs.data, service);
93
95
  for (const { state, generation, view, position: pos } of states) {
@@ -8,6 +8,7 @@ import { join, resolve } from 'node:path';
8
8
  import { getActiveWorldStore, readBranchMeta, withAncestryLock, writeBranchMeta } from '@volter/world-core';
9
9
  import { saveWorldInstance, statusWorld } from "./runtime.js";
10
10
  import { fetchReady, isPathRemote, localWorld, TOKEN_HEADER } from "./served-world.js";
11
+ import { loadWorldConfig, writeWorldConfig } from "./configs.js";
11
12
  function originOf(instance, options) {
12
13
  const url = options.url ?? instance.origin?.url;
13
14
  const namespace = options.namespace ?? instance.origin?.namespace;
@@ -132,6 +133,20 @@ export async function fetchFromOrigin(name, options) {
132
133
  });
133
134
  }
134
135
  saveWorldInstance({ ...instance, origin: { ...origin, ...(options.full ? { clonedAt: at_ } : instance.origin?.clonedAt ? { clonedAt: instance.origin.clonedAt } : {}), fetchedAt: at_ } });
136
+ // a clone names its remote where `volter remote add` does (world.json, as git config holds a remote): one record of
137
+ // it, which every restart carries (runtime's namesOrigin) and `remote remove` ends. Only the World the config is
138
+ // for writes it: a runtime branch boots from its base's config, whose remote stays the base's
139
+ if (options.full && instance.name === instance.config) {
140
+ try {
141
+ const { path: configPath, config } = loadWorldConfig(instance.configPath, root);
142
+ const named = isPathRemote(origin.url) ? origin.url : `${origin.url}/${origin.namespace}`;
143
+ if (config.remotes?.origin !== named)
144
+ writeWorldConfig(configPath, { ...config, remotes: { ...(config.remotes ?? {}), origin: named } });
145
+ }
146
+ catch (error) {
147
+ console.error(`[origin] ${name}: the remote was not named in its config: ${error.message}`);
148
+ }
149
+ }
135
150
  return { world: name, origin, full: options.full ?? false, appended, behind, snapshots };
136
151
  }
137
152
  export function worldOrigin(name, root) {
@@ -728,6 +728,22 @@ function writePidsFromInstance(instance, fresh = []) {
728
728
  ].filter((pid) => Number.isInteger(pid) && pid > 0))];
729
729
  writeOwnedPids(instance.root, instance.name, pids, fresh);
730
730
  }
731
+ /** Whether a world.json remote (`https://<host>/<org>/<world>`, or a world's directory, relative to the World's root) is
732
+ * the recorded origin, read as the CLI's parseOriginUrl reads it (the URL's origin, then its two path parts). */
733
+ function namesOrigin(remote, origin, worldRoot) {
734
+ if (!remote)
735
+ return false;
736
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote))
737
+ return resolve(worldRoot, remote) === resolve(worldRoot, origin.url);
738
+ try {
739
+ const parsed = new URL(remote);
740
+ const parts = parsed.pathname.split('/').filter(Boolean);
741
+ return parts.length === 2 && parsed.origin === new URL(origin.url).origin && parts.join('/') === origin.namespace;
742
+ }
743
+ catch {
744
+ return false;
745
+ }
746
+ }
731
747
  export function saveWorldInstance(instance) {
732
748
  writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
733
749
  }
@@ -1483,6 +1499,7 @@ export async function upWorld(configId, options = {}, abort) {
1483
1499
  const pidsFile = join(instance, 'pids');
1484
1500
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1485
1501
  const instanceFile = join(instance, 'instance.json');
1502
+ let carriedOrigin;
1486
1503
  const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) : undefined;
1487
1504
  const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1488
1505
  ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
@@ -1536,6 +1553,14 @@ export async function upWorld(configId, options = {}, abort) {
1536
1553
  || !Number.isFinite(Date.parse(receipt.stoppedAt))) {
1537
1554
  throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
1538
1555
  }
1556
+ // the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
1557
+ // same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
1558
+ // ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
1559
+ // only when its state (the mirror they count) is kept, as reset drops them
1560
+ if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
1561
+ carriedOrigin = options.keepState ? prior.origin
1562
+ : { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
1563
+ }
1539
1564
  }
1540
1565
  assertLifecycleStartable(root, name);
1541
1566
  beginWorldLifecycle(root, name, createdAt, options.owner);
@@ -1643,6 +1668,7 @@ export async function upWorld(configId, options = {}, abort) {
1643
1668
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1644
1669
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
1645
1670
  lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
1671
+ ...(carriedOrigin ? { origin: carriedOrigin } : {}),
1646
1672
  };
1647
1673
  saveWorldInstance(worldInstance);
1648
1674
  const cancelled = new AbortController();
@@ -268,6 +268,10 @@ export declare class WorldDoors {
268
268
  /** What this World issued, for world-access to decide a grant against: its two tokens, its named keys, its sessions. */
269
269
  private issued;
270
270
  private credential;
271
+ /** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
272
+ * performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
273
+ * changeset's own `applied`). */
274
+ private deployedOf;
271
275
  /** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
272
276
  * (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
273
277
  * this World issued. */
@@ -658,6 +658,33 @@ export class WorldDoors {
658
658
  return { scope: grant.scope, via: 'session', token: null, session: grant.session, ...(grant.who ? { who: grant.who } : {}) };
659
659
  return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
660
660
  }
661
+ /** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
662
+ * performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
663
+ * changeset's own `applied`). */
664
+ deployedOf(changesets) {
665
+ // by the state each action was written in (a twin may hold several), and a settled receipt wins over a provisional
666
+ // `landed` one whichever comes later in the log (a push's local copy, then the remote's deploy fetched in)
667
+ const landed = new Map();
668
+ const of = (service, state) => {
669
+ const at = `${service}\u0000${state}`;
670
+ let held = landed.get(at);
671
+ if (!held) {
672
+ held = new Map();
673
+ if (this.up().services[service]) {
674
+ for (const e of parentEntries(state, this.controlRoot(service))) {
675
+ if (!e.landsId || !e.receipt)
676
+ continue;
677
+ const before = held.get(e.landsId);
678
+ if (!before || before.status === 'landed' || e.receipt.status !== 'landed')
679
+ held.set(e.landsId, e.receipt);
680
+ }
681
+ }
682
+ landed.set(at, held);
683
+ }
684
+ return held;
685
+ };
686
+ return changesets.map((c) => c.actions.flatMap((a) => { const r = of(a.service, a.stateService ?? this.stateOf(a.service)).get(a.action.id); return r ? [{ service: a.service, actionId: a.action.id, ...r }] : []; }));
687
+ }
661
688
  /** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
662
689
  * (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
663
690
  * this World issued. */
@@ -1198,9 +1225,11 @@ export class WorldDoors {
1198
1225
  // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
1199
1226
  if (name) {
1200
1227
  const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
1201
- return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) });
1228
+ return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] });
1202
1229
  }
1203
- return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => ({ ...l.changeset, currentHash: changesetContentHash(l.changeset) })) });
1230
+ const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
1231
+ const deployed = this.deployedOf(all);
1232
+ return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
1204
1233
  }
1205
1234
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
1206
1235
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.40",
3
+ "version": "2.0.41",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.22",
70
+ "@volter/world-core": "2.0.23",
71
71
  "@volter/world-console": "2.0.13"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.22",
77
+ "@volter/world-core": "2.0.23",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.22"
87
+ "@volter/twin-mongodb": "0.1.23"
88
88
  }
89
89
  }
package/src/branch.ts CHANGED
@@ -69,7 +69,9 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
69
69
  }
70
70
  const forked: Record<string, string[]> = {};
71
71
  try {
72
- if (from.origin) { instance.origin = { ...from.origin }; saveWorldInstance(instance); }
72
+ // the branch's origin is its base's, or none: never one a stopped World of the same name left
73
+ if (from.origin) instance.origin = { ...from.origin }; else delete instance.origin;
74
+ saveWorldInstance(instance);
73
75
  for (const { service, fromRoot, states } of source) {
74
76
  const toRoot = join(instance.dirs.data, service);
75
77
  for (const { state, generation, view, position: pos } of states) {
package/src/origin.ts CHANGED
@@ -9,6 +9,7 @@ import { appendOriginEntry, appendParentEntry, branchEntries, getActiveWorldStor
9
9
  import { saveWorldInstance, statusWorld } from './runtime.ts';
10
10
  import type { WorldInstance } from './schema.ts';
11
11
  import { fetchReady, isPathRemote, localWorld, TOKEN_HEADER } from './served-world.ts';
12
+ import { loadWorldConfig, writeWorldConfig } from './configs.ts';
12
13
 
13
14
  export type FetchOptions = { root?: string; url?: string; namespace?: string; key: string; full?: boolean; services?: string[]; views?: Record<string, HistoryReference>; now?: Date };
14
15
  export type FetchOutcome = {
@@ -126,6 +127,16 @@ export async function fetchFromOrigin(name: string, options: FetchOptions): Prom
126
127
 
127
128
  }
128
129
  saveWorldInstance({ ...instance, origin: { ...origin, ...(options.full ? { clonedAt: at_ } : instance.origin?.clonedAt ? { clonedAt: instance.origin.clonedAt } : {}), fetchedAt: at_ } });
130
+ // a clone names its remote where `volter remote add` does (world.json, as git config holds a remote): one record of
131
+ // it, which every restart carries (runtime's namesOrigin) and `remote remove` ends. Only the World the config is
132
+ // for writes it: a runtime branch boots from its base's config, whose remote stays the base's
133
+ if (options.full && instance.name === instance.config) {
134
+ try {
135
+ const { path: configPath, config } = loadWorldConfig(instance.configPath, root);
136
+ const named = isPathRemote(origin.url) ? origin.url : `${origin.url}/${origin.namespace}`;
137
+ if (config.remotes?.origin !== named) writeWorldConfig(configPath, { ...config, remotes: { ...(config.remotes ?? {}), origin: named } });
138
+ } catch (error) { console.error(`[origin] ${name}: the remote was not named in its config: ${(error as Error).message}`); }
139
+ }
129
140
  return { world: name, origin, full: options.full ?? false, appended, behind, snapshots };
130
141
  }
131
142
 
package/src/runtime.ts CHANGED
@@ -757,6 +757,17 @@ function writePidsFromInstance(instance: WorldInstance, fresh: number[] = []): v
757
757
  writeOwnedPids(instance.root, instance.name, pids, fresh);
758
758
  }
759
759
 
760
+ /** Whether a world.json remote (`https://<host>/<org>/<world>`, or a world's directory, relative to the World's root) is
761
+ * the recorded origin, read as the CLI's parseOriginUrl reads it (the URL's origin, then its two path parts). */
762
+ function namesOrigin(remote: string | undefined, origin: { url: string; namespace: string }, worldRoot: string): boolean {
763
+ if (!remote) return false;
764
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) return resolve(worldRoot, remote) === resolve(worldRoot, origin.url);
765
+ try {
766
+ const parsed = new URL(remote); const parts = parsed.pathname.split('/').filter(Boolean);
767
+ return parts.length === 2 && parsed.origin === new URL(origin.url).origin && parts.join('/') === origin.namespace;
768
+ } catch { return false; }
769
+ }
770
+
760
771
  export function saveWorldInstance(instance: WorldInstance): void {
761
772
  writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
762
773
  }
@@ -1483,6 +1494,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1483
1494
  const pidsFile = join(instance, 'pids');
1484
1495
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1485
1496
  const instanceFile = join(instance, 'instance.json');
1497
+ let carriedOrigin: WorldInstance['origin'] | undefined;
1486
1498
  const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) as WorldInstance : undefined;
1487
1499
  const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1488
1500
  ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
@@ -1537,6 +1549,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1537
1549
  || !Number.isFinite(Date.parse(receipt.stoppedAt))) {
1538
1550
  throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
1539
1551
  }
1552
+ // the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
1553
+ // same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
1554
+ // ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
1555
+ // only when its state (the mirror they count) is kept, as reset drops them
1556
+ if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
1557
+ carriedOrigin = options.keepState ? prior.origin
1558
+ : { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
1559
+ }
1540
1560
  }
1541
1561
  assertLifecycleStartable(root, name);
1542
1562
  beginWorldLifecycle(root, name, createdAt, options.owner);
@@ -1622,6 +1642,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1622
1642
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1623
1643
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
1624
1644
  lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
1645
+ ...(carriedOrigin ? { origin: carriedOrigin } : {}),
1625
1646
  };
1626
1647
  saveWorldInstance(worldInstance);
1627
1648
  const cancelled = new AbortController();
@@ -595,6 +595,32 @@ export class WorldDoors {
595
595
  if (grant.via === 'session') return { scope: grant.scope, via: 'session', token: null, session: grant.session!, ...(grant.who ? { who: grant.who } : {}) };
596
596
  return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
597
597
  }
598
+ /** Each changeset's actions' receipts, from the landed copies on each twin's log: a deploy appends one per entry it
599
+ * performed, and a push one per entry it landed at the remote (`landed` while the remote waits, beside the
600
+ * changeset's own `applied`). */
601
+ private deployedOf(changesets: Changeset[]): Array<Array<{ service: string; actionId: string } & Receipt>> {
602
+ // by the state each action was written in (a twin may hold several), and a settled receipt wins over a provisional
603
+ // `landed` one whichever comes later in the log (a push's local copy, then the remote's deploy fetched in)
604
+ const landed = new Map<string, Map<string, Receipt>>();
605
+ const of = (service: string, state: string): Map<string, Receipt> => {
606
+ const at = `${service}\u0000${state}`;
607
+ let held = landed.get(at);
608
+ if (!held) {
609
+ held = new Map();
610
+ if (this.up().services[service]) {
611
+ for (const e of parentEntries(state, this.controlRoot(service))) {
612
+ if (!e.landsId || !e.receipt) continue;
613
+ const before = held.get(e.landsId);
614
+ if (!before || before.status === 'landed' || e.receipt.status !== 'landed') held.set(e.landsId, e.receipt);
615
+ }
616
+ }
617
+ landed.set(at, held);
618
+ }
619
+ return held;
620
+ };
621
+ return changesets.map((c) => c.actions.flatMap((a) => { const r = of(a.service, a.stateService ?? this.stateOf(a.service)).get(a.action.id); return r ? [{ service: a.service, actionId: a.action.id, ...r }] : []; }));
622
+ }
623
+
598
624
  /** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
599
625
  * (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
600
626
  * this World issued. */
@@ -1040,8 +1066,10 @@ export class WorldDoors {
1040
1066
  }
1041
1067
  if (kind === 'changesets' && request.method === 'GET') {
1042
1068
  // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
1043
- if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset) }); }
1044
- return Response.json({ changesets: listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => ({ ...l.changeset, currentHash: changesetContentHash(l.changeset) })) });
1069
+ if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] }); }
1070
+ const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
1071
+ const deployed = this.deployedOf(all);
1072
+ return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
1045
1073
  }
1046
1074
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
1047
1075
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));