@volter/world-runtime 2.0.14 → 2.0.16

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.
@@ -55,22 +55,26 @@ export async function branchWorld(base, name, options = {}) {
55
55
  catch {
56
56
  return false;
57
57
  } })();
58
+ const began = Date.now();
58
59
  let instance;
59
60
  try {
60
61
  instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
61
62
  }
62
63
  catch (error) {
63
- let recorded = false;
64
+ // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
65
+ // cleanup below does: a concurrent `branch` of the same name that recorded first keeps its World
66
+ let recorded = null;
64
67
  if (!existed) {
65
68
  try {
66
- statusWorld(name, root);
67
- recorded = true;
69
+ const made = statusWorld(name, root).createdAt;
70
+ if (Date.parse(made) >= began)
71
+ recorded = made;
68
72
  }
69
73
  catch { /* the boot recorded nothing */ }
70
74
  }
71
75
  if (recorded) {
72
76
  try {
73
- await downWorld(name, root, { purge: true });
77
+ await downWorld(name, root, { purge: true, expectedCreatedAt: recorded });
74
78
  }
75
79
  catch (cleanup) {
76
80
  throw new Error(`${error instanceof Error ? error.message : String(error)} (and the half-made branch ${name} could not be removed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)})`);
@@ -156,7 +156,8 @@ export async function mountWorld(name, opts = {}) {
156
156
  const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
157
157
  const keep = () => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
158
158
  let timers = [];
159
- return {
159
+ let stopping = null;
160
+ const mounted = {
160
161
  name, served, root: worldRoot,
161
162
  get token() { return world.token; }, get readToken() { return world.readToken; },
162
163
  twins: () => world.twins(),
@@ -166,24 +167,67 @@ export async function mountWorld(name, opts = {}) {
166
167
  keep();
167
168
  const port = Number(new URL(url).port) || 0;
168
169
  writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
169
- const booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
170
- instance = booted;
171
- // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
172
- materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
173
- timers = await scheduleRefreshes(name, worldRoot, booted);
174
- },
175
- async stop() {
176
- for (const t of timers)
177
- clearInterval(t);
170
+ // a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
171
+ // refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
172
+ let booted = null;
178
173
  try {
179
- rmSync(record, { force: true });
174
+ booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
175
+ instance = booted;
176
+ // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
177
+ materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
178
+ timers = await scheduleRefreshes(name, worldRoot, booted);
180
179
  }
181
- catch { /* already gone */ }
182
- const result = await downWorld(name, worldRoot);
183
- if (result.externalErrors.length)
184
- throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
180
+ catch (error) {
181
+ for (const t of timers)
182
+ clearInterval(t);
183
+ timers = [];
184
+ if (booted) {
185
+ try {
186
+ await downWorld(name, worldRoot);
187
+ }
188
+ catch { /* the boot's own error is the one reported */ }
189
+ }
190
+ try {
191
+ rmSync(record, { force: true });
192
+ }
193
+ catch { /* already gone */ }
194
+ throw error;
195
+ }
196
+ },
197
+ stop() {
198
+ // one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
199
+ stopping ??= stopOnce().finally(() => { stopping = null; });
200
+ return stopping;
185
201
  },
186
202
  };
203
+ async function stopOnce() {
204
+ // refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
205
+ // down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
206
+ // refused before anything stopped, the World is served as it was; failed part way, its record names what remains
207
+ for (const t of timers)
208
+ clearInterval(t);
209
+ timers = [];
210
+ let result;
211
+ try {
212
+ result = await downWorld(name, worldRoot);
213
+ if (result.externalErrors.length)
214
+ throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
215
+ }
216
+ catch (error) {
217
+ if (instance) {
218
+ try {
219
+ timers = await scheduleRefreshes(name, worldRoot, instance);
220
+ }
221
+ catch { /* the stop's error is the one reported */ }
222
+ }
223
+ throw error;
224
+ }
225
+ try {
226
+ rmSync(record, { force: true });
227
+ }
228
+ catch { /* already gone */ }
229
+ }
230
+ return mounted;
187
231
  }
188
232
  /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
189
233
  const localMirrors = new Map();
@@ -1438,7 +1482,7 @@ export class WorldDoors {
1438
1482
  named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running }));
1439
1483
  }
1440
1484
  catch { /* a World with no local branches */ }
1441
- return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1485
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1442
1486
  }
1443
1487
  // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1444
1488
  if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
@@ -55,6 +55,7 @@ export async function serveWorldView(name, opts = {}) {
55
55
  switching = true;
56
56
  const from = world.name;
57
57
  try {
58
+ // a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
58
59
  await world.stop();
59
60
  let next;
60
61
  try {
@@ -62,6 +63,7 @@ export async function serveWorldView(name, opts = {}) {
62
63
  await next.boot(url);
63
64
  }
64
65
  catch (error) {
66
+ // the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
65
67
  const back = await mountWorld(from, mountOpts);
66
68
  await back.boot(url);
67
69
  worlds.set(back.served, back);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
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.8",
71
- "@volter/world-console": "2.0.8"
70
+ "@volter/world-core": "2.0.10",
71
+ "@volter/world-console": "2.0.9"
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.1",
77
- "@volter/world-core": "2.0.8",
77
+ "@volter/world-core": "2.0.10",
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.8"
87
+ "@volter/twin-mongodb": "0.1.10"
88
88
  }
89
89
  }
package/src/branch.ts CHANGED
@@ -51,14 +51,17 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
51
51
  // a branch that fails to boot (a twin whose mounted version breaks the config's pin) leaves no half-made branch behind:
52
52
  // its record without a fork would check out later as an empty World
53
53
  const existed = (() => { try { statusWorld(name, root); return true; } catch { return false; } })();
54
+ const began = Date.now();
54
55
  let instance: WorldInstance;
55
56
  try {
56
57
  instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
57
58
  } catch (error) {
58
- let recorded = false;
59
- if (!existed) { try { statusWorld(name, root); recorded = true; } catch { /* the boot recorded nothing */ } }
59
+ // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
60
+ // cleanup below does: a concurrent `branch` of the same name that recorded first keeps its World
61
+ let recorded: string | null = null;
62
+ if (!existed) { try { const made = statusWorld(name, root).createdAt; if (Date.parse(made) >= began) recorded = made; } catch { /* the boot recorded nothing */ } }
60
63
  if (recorded) {
61
- try { await downWorld(name, root, { purge: true }); } catch (cleanup) {
64
+ try { await downWorld(name, root, { purge: true, expectedCreatedAt: recorded }); } catch (cleanup) {
62
65
  throw new Error(`${error instanceof Error ? error.message : String(error)} (and the half-made branch ${name} could not be removed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)})`);
63
66
  }
64
67
  }
@@ -161,7 +161,8 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
161
161
  const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
162
162
  const keep = (): void => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
163
163
  let timers: ReturnType<typeof setInterval>[] = [];
164
- return {
164
+ let stopping: Promise<void> | null = null;
165
+ const mounted: MountedWorld = {
165
166
  name, served, root: worldRoot,
166
167
  get token() { return world.token; }, get readToken() { return world.readToken; },
167
168
  twins: () => world.twins(),
@@ -171,19 +172,46 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
171
172
  keep();
172
173
  const port = Number(new URL(url).port) || 0;
173
174
  writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
174
- const booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
175
- instance = booted;
176
- // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
177
- materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
178
- timers = await scheduleRefreshes(name, worldRoot, booted);
175
+ // a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
176
+ // refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
177
+ let booted: WorldInstance | null = null;
178
+ try {
179
+ booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
180
+ instance = booted;
181
+ // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
182
+ materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
183
+ timers = await scheduleRefreshes(name, worldRoot, booted);
184
+ } catch (error) {
185
+ for (const t of timers) clearInterval(t);
186
+ timers = [];
187
+ if (booted) { try { await downWorld(name, worldRoot); } catch { /* the boot's own error is the one reported */ } }
188
+ try { rmSync(record, { force: true }); } catch { /* already gone */ }
189
+ throw error;
190
+ }
179
191
  },
180
- async stop() {
181
- for (const t of timers) clearInterval(t);
182
- try { rmSync(record, { force: true }); } catch { /* already gone */ }
183
- const result = await downWorld(name, worldRoot);
184
- if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
192
+ stop() {
193
+ // one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
194
+ stopping ??= stopOnce().finally(() => { stopping = null; });
195
+ return stopping;
185
196
  },
186
197
  };
198
+ async function stopOnce(): Promise<void> {
199
+ // refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
200
+ // down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
201
+ // refused before anything stopped, the World is served as it was; failed part way, its record names what remains
202
+ for (const t of timers) clearInterval(t);
203
+ timers = [];
204
+ let result: Awaited<ReturnType<typeof downWorld>>;
205
+ try {
206
+ result = await downWorld(name, worldRoot);
207
+ if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
208
+ } catch (error) {
209
+ if (instance) { try { timers = await scheduleRefreshes(name, worldRoot, instance); } catch { /* the stop's error is the one reported */ } }
210
+ throw error;
211
+ }
212
+ try { rmSync(record, { force: true }); } catch { /* already gone */ }
213
+ }
214
+ return mounted;
187
215
  }
188
216
 
189
217
  /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
@@ -1234,7 +1262,7 @@ export class WorldDoors {
1234
1262
  // `checkedOut` is the one this serve holds, so a page never names the World for the branch it shows
1235
1263
  let named: Array<{ name: string; running: boolean }> = [];
1236
1264
  try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
1237
- return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1265
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1238
1266
  }
1239
1267
  // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1240
1268
  if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
package/src/world-view.ts CHANGED
@@ -55,12 +55,14 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
55
55
  switching = true;
56
56
  const from = world.name;
57
57
  try {
58
+ // a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
58
59
  await world.stop();
59
60
  let next: MountedWorld;
60
61
  try {
61
62
  next = await mountWorld(branch, mountOpts);
62
63
  await next.boot(url);
63
64
  } catch (error) {
65
+ // the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
64
66
  const back = await mountWorld(from, mountOpts);
65
67
  await back.boot(url);
66
68
  worlds.set(back.served, back); world = back;