@volter/world-runtime 3.0.162 → 3.0.164

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.
Files changed (140) hide show
  1. package/dist/src/ancestry.js +2 -2
  2. package/dist/src/app-url.d.ts +5 -1
  3. package/dist/src/app-url.js +14 -1
  4. package/dist/src/branch.js +28 -8
  5. package/dist/src/browser-files.d.ts +5 -0
  6. package/dist/src/browser-files.js +14 -0
  7. package/dist/src/browsers.d.ts +2 -3
  8. package/dist/src/browsers.js +4 -4
  9. package/dist/src/catalog.d.ts +2 -1
  10. package/dist/src/catalog.js +3 -2
  11. package/dist/src/changeset.js +1 -1
  12. package/dist/src/cli-commands.js +71 -18
  13. package/dist/src/cli-load-clock.d.ts +1 -0
  14. package/dist/src/cli-load-clock.js +2 -0
  15. package/dist/src/cli.js +5 -1
  16. package/dist/src/clickhouse-backing.d.ts +2 -0
  17. package/dist/src/clickhouse-backing.js +145 -0
  18. package/dist/src/command-lifetime.d.mts +1 -1
  19. package/dist/src/command-lifetime.mjs +5 -2
  20. package/dist/src/configs.js +1 -1
  21. package/dist/src/consumers.js +1 -1
  22. package/dist/src/covers.d.ts +4 -2
  23. package/dist/src/covers.js +8 -4
  24. package/dist/src/go-mysql-binding.d.ts +19 -0
  25. package/dist/src/go-mysql-binding.js +51 -0
  26. package/dist/src/guest-processes.d.mts +1 -1
  27. package/dist/src/guest-processes.mjs +22 -14
  28. package/dist/src/host-cli.js +8 -1
  29. package/dist/src/host-ready.d.ts +16 -2
  30. package/dist/src/host-ready.js +66 -39
  31. package/dist/src/host-worker.js +7 -1
  32. package/dist/src/host.d.ts +26 -0
  33. package/dist/src/host.js +93 -3
  34. package/dist/src/infra-definition.d.ts +35 -0
  35. package/dist/src/infra-definition.js +146 -0
  36. package/dist/src/infra.d.ts +3 -0
  37. package/dist/src/infra.js +375 -105
  38. package/dist/src/init.d.ts +46 -0
  39. package/dist/src/init.js +445 -18
  40. package/dist/src/lifecycle-record.d.ts +5 -1
  41. package/dist/src/lifecycle-record.js +27 -4
  42. package/dist/src/local-branches.js +1 -1
  43. package/dist/src/mariadb-bootstrap.d.ts +32 -0
  44. package/dist/src/mariadb-bootstrap.js +90 -0
  45. package/dist/src/mariadb-supplier.d.ts +23 -0
  46. package/dist/src/mariadb-supplier.js +215 -0
  47. package/dist/src/mongodb-backing.d.ts +4 -0
  48. package/dist/src/mongodb-backing.js +71 -1
  49. package/dist/src/native-mariadb-backing.d.ts +13 -0
  50. package/dist/src/native-mariadb-backing.js +512 -0
  51. package/dist/src/native-postgres-backing.d.ts +4 -10
  52. package/dist/src/native-postgres-backing.js +428 -43
  53. package/dist/src/native-server-inputs.d.ts +16 -0
  54. package/dist/src/native-server-inputs.js +204 -0
  55. package/dist/src/origin.js +3 -3
  56. package/dist/src/original-inputs.d.ts +14 -0
  57. package/dist/src/original-inputs.js +84 -0
  58. package/dist/src/pack-facts.d.ts +9 -1
  59. package/dist/src/pglite-backing.d.ts +41 -17
  60. package/dist/src/pglite-backing.js +213 -79
  61. package/dist/src/pglite-host.mjs +482 -10
  62. package/dist/src/process-groups.d.mts +6 -1
  63. package/dist/src/process-groups.d.ts +2 -1
  64. package/dist/src/process-groups.js +1 -1
  65. package/dist/src/process-groups.mjs +82 -15
  66. package/dist/src/process-signal.d.mts +2 -2
  67. package/dist/src/process-signal.mjs +29 -9
  68. package/dist/src/project-inspect.d.ts +12 -0
  69. package/dist/src/project-inspect.js +41 -4
  70. package/dist/src/proxy-env.d.ts +34 -0
  71. package/dist/src/proxy-env.js +114 -0
  72. package/dist/src/redirect-proxy.d.ts +1 -31
  73. package/dist/src/redirect-proxy.js +3 -112
  74. package/dist/src/redis-backing.d.ts +6 -4
  75. package/dist/src/redis-backing.js +20 -168
  76. package/dist/src/runtime.d.ts +1 -0
  77. package/dist/src/runtime.js +503 -218
  78. package/dist/src/schema.d.ts +1 -0
  79. package/dist/src/schema.js +5 -3
  80. package/dist/src/served-world.js +15 -10
  81. package/dist/src/service-exit.d.ts +3 -2
  82. package/dist/src/service-exit.js +7 -5
  83. package/dist/src/twin-backing.d.ts +47 -0
  84. package/dist/src/twin-backing.js +343 -0
  85. package/dist/src/up-cli.js +56 -3
  86. package/package.json +4 -4
  87. package/src/ancestry.ts +2 -2
  88. package/src/app-url.ts +9 -2
  89. package/src/branch.ts +26 -8
  90. package/src/browser-files.ts +15 -0
  91. package/src/browsers.ts +4 -4
  92. package/src/catalog.ts +4 -3
  93. package/src/changeset.ts +1 -1
  94. package/src/cli-commands.ts +85 -18
  95. package/src/cli-load-clock.ts +2 -0
  96. package/src/cli.ts +5 -1
  97. package/src/clickhouse-backing.ts +70 -0
  98. package/src/command-lifetime.d.mts +1 -1
  99. package/src/command-lifetime.mjs +5 -2
  100. package/src/configs.ts +1 -1
  101. package/src/consumers.ts +1 -1
  102. package/src/covers.ts +9 -4
  103. package/src/go-mysql-binding.ts +49 -0
  104. package/src/guest-processes.d.mts +1 -1
  105. package/src/guest-processes.mjs +22 -14
  106. package/src/host-cli.ts +7 -1
  107. package/src/host-ready.ts +62 -31
  108. package/src/host-worker.ts +6 -2
  109. package/src/host.ts +88 -6
  110. package/src/infra-definition.ts +135 -0
  111. package/src/infra.ts +250 -45
  112. package/src/init.ts +416 -17
  113. package/src/lifecycle-record.ts +18 -4
  114. package/src/local-branches.ts +1 -1
  115. package/src/mariadb-bootstrap.ts +107 -0
  116. package/src/mariadb-supplier.ts +199 -0
  117. package/src/mongodb-backing.ts +59 -1
  118. package/src/native-mariadb-backing.ts +418 -0
  119. package/src/native-postgres-backing.ts +353 -41
  120. package/src/native-server-inputs.ts +169 -0
  121. package/src/origin.ts +3 -3
  122. package/src/original-inputs.ts +72 -0
  123. package/src/pack-facts.ts +1 -1
  124. package/src/pglite-backing.ts +192 -80
  125. package/src/pglite-host.mjs +482 -10
  126. package/src/process-groups.d.mts +6 -1
  127. package/src/process-groups.mjs +82 -15
  128. package/src/process-groups.ts +2 -1
  129. package/src/process-signal.d.mts +2 -2
  130. package/src/process-signal.mjs +29 -9
  131. package/src/project-inspect.ts +37 -4
  132. package/src/proxy-env.ts +128 -0
  133. package/src/redirect-proxy.ts +3 -125
  134. package/src/redis-backing.ts +19 -136
  135. package/src/runtime.ts +342 -107
  136. package/src/schema.ts +5 -4
  137. package/src/served-world.ts +14 -10
  138. package/src/service-exit.ts +5 -3
  139. package/src/twin-backing.ts +300 -0
  140. package/src/up-cli.ts +52 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.162",
3
+ "version": "3.0.164",
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",
@@ -70,8 +70,8 @@
70
70
  "node": ">=22.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@volter/world-core": "3.0.154",
74
- "@volter/world-console": "3.0.150"
73
+ "@volter/world-core": "3.0.156",
74
+ "@volter/world-console": "3.0.151"
75
75
  },
76
76
  "dependencies": {
77
77
  "@electric-sql/pglite": "0.5.8",
@@ -80,7 +80,7 @@
80
80
  "@volter/browser-runtime": "0.1.104",
81
81
  "@volter/browser-sandboxes": "0.1.84",
82
82
  "@volter/world-access": "3.0.1",
83
- "@volter/world-core": "3.0.154",
83
+ "@volter/world-core": "3.0.156",
84
84
  "modern-screenshot": "4.7.0",
85
85
  "mongodb": "~6.20.0",
86
86
  "pg-gateway": "0.3.0-beta.4",
package/src/ancestry.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // carry parent-side pins, including clones across checkouts, without a global World registry.
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { join, relative, resolve, sep } from 'node:path';
5
- import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
5
+ import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime-state';
6
6
 
7
7
  function checkWorldAncestry(root: string, path: string): void {
8
8
  const base = join(root, stateDirName(), 'worlds');
@@ -28,7 +28,7 @@ function checkWorldAncestry(root: string, path: string): void {
28
28
  }
29
29
 
30
30
  export function assertWorldStateRemovable(root: string, path: string): void {
31
- withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); });
31
+ withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); }, { operation: 'removal preflight' });
32
32
  }
33
33
 
34
34
  export function removeWorldState<T>(root: string, path: string, remove: () => T): T {
package/src/app-url.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // instance it described.
22
22
  import { errorMessage } from './error-message.ts';
23
23
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
- import { withFileLock } from '@volter/world-core/runtime';
24
+ import { withFileLock } from '@volter/world-core/runtime-state';
25
25
  import { join } from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import net from 'node:net';
@@ -113,11 +113,18 @@ function assertHttpUrl(url: string): URL {
113
113
  /** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
114
114
  * exist (a record for a world that was never upped would describe nothing); the app itself is
115
115
  * deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
116
- export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string } = {}): AppUrlRecord {
116
+ export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string; /** An instrument: filled with how long the World's status and the record's write each took. */ timings?: { statusMs?: number; writeMs?: number } } = {}): AppUrlRecord {
117
117
  const root = options.root ?? process.cwd();
118
118
  const app = appName(options.app);
119
+ const began = Date.now();
119
120
  const status = statusWorld(name, root); // throws "World instance not found" — the loud path
121
+ const statusRead = Date.now();
122
+ if (options.timings) options.timings.statusMs = statusRead - began;
120
123
  assertHttpUrl(url);
124
+ try { return setAppUrlEntry(root, name, app, url, status, options); }
125
+ finally { if (options.timings) options.timings.writeMs = Date.now() - statusRead; }
126
+ }
127
+ function setAppUrlEntry(root: string, name: string, app: string, url: string, status: ReturnType<typeof statusWorld>, options: { via?: Exclude<AppUrlSource, 'service'>; detail?: string }): AppUrlRecord {
121
128
  return writeEntry(root, name, app, (file) => {
122
129
  // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
123
130
  const hosts = entryOf(file, app)?.hosts;
package/src/branch.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // base running at once share the database port its definition names; run one at a time (the SDK does). `checkout <name>`
8
8
  // brings a stopped world back up from its own instance record, state intact.
9
9
  import { runInfrastructure } from './infra.ts';
10
- import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
10
+ import { cpSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
11
11
  import { dirname, join, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withHistoryLocks, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
@@ -49,7 +49,7 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
49
49
  const position = options.at?.positions?.[service] ?? options.at?.positions?.[state] ?? timed?.position;
50
50
  return { state, view, position, generation: stateGeneration(worldPaths(state, fromRoot).dir) };
51
51
  }) };
52
- }));
52
+ }), { operation: 'capture' });
53
53
  // the base's managed databases (checked before the branch boots: its database would share the base's port)
54
54
  const databases = managedDatabases(from);
55
55
  if (databases.length && (options.at?.instant || options.at?.positions || options.at?.views)) {
@@ -83,23 +83,28 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
83
83
  }
84
84
 
85
85
  /** The base's containerless managed databases: each PGlite `pglite-<kind>-data`, MongoDB's
86
- * `mongod-mongodb-data` (pglite-backing.ts), the redis twin's `redis-twin` tree (redis-backing.ts), and native PostgreSQL. A stopped World's
86
+ * `mongod-mongodb-data` (pglite-backing.ts), the declared Redis/ClickHouse twin trees, and native PostgreSQL. A stopped World's
87
87
  * hosts leave no pid file; a docker-backed World keeps its data under the container's volume, which a branch does not
88
88
  * carry. */
89
89
  function managedDatabases(from: WorldInstance): string[] {
90
90
  const data = from.dirs.data;
91
91
  if (!existsSync(data)) return [];
92
- const known = (name: string) => /^pglite-[a-z0-9_-]+-data$/.test(name) || name === 'mongod-mongodb-data' || name === 'native-postgres-data';
92
+ const known = (name: string) => /^pglite-[a-z0-9_-]+-data$/.test(name) || name === 'mongod-mongodb-data' || ['native-postgres-data', 'native-mariadb-data'].includes(name);
93
93
  if (readdirSync(data).some(name => name.endsWith('-data') && !known(name) && !from.services[name])) {
94
94
  throw new Error('volter-world branch: unrecognised storage; retain the original World');
95
95
  }
96
- return readdirSync(data).filter((n) => /^pglite-[a-z0-9_-]+-data$/.test(n) || n === 'mongod-mongodb-data' || n === 'redis-twin' || n === 'native-postgres-data');
96
+ return readdirSync(data).filter((n) => /^pglite-[a-z0-9_-]+-data$/.test(n) || n === 'mongod-mongodb-data' || ['redis-twin', 'clickhouse-twin', 'native-postgres-data', 'native-mariadb-data'].includes(n));
97
97
  }
98
98
 
99
99
  /** The pid files of the hosts serving one managed database directory. */
100
100
  function hostPidFiles(data: string, db: string): string[] {
101
+ if (db === 'native-mariadb-data') return readdirSync(data).filter(name => /^native-mariadb\.(?:owner|starting)\.json(?:\.spawn\.json)?$/u.test(name)
102
+ || /^native-mariadb-tool-.*\.json(?:\.spawn\.json)?$/u.test(name)).map(name => join(data, name));
101
103
  if (db === 'native-postgres-data') return [join(data, 'native-postgres.owner.json')];
102
104
  if (db === 'redis-twin') return readdirSync(data).filter((n) => /^redis-twin-\d+\.pid$/.test(n)).map((n) => join(data, n));
105
+ if (db === 'clickhouse-twin') return [...new Set(readdirSync(data)
106
+ .filter(n => new RegExp(`^${db}-\\d+\\.pid(?:\\.spawn\\.json)?$`).test(n))
107
+ .map(n => join(data, n.replace(/\.spawn\.json$/, ''))))];
103
108
  return [join(data, `${db.slice(0, -'-data'.length)}.pid`)];
104
109
  }
105
110
 
@@ -107,7 +112,19 @@ function hostPidFiles(data: string, db: string): string[] {
107
112
  * port: the base must be stopped first (`volter world branch` stops it, as a branch replaces its base). */
108
113
  function refuseLiveDatabases(from: WorldInstance, dbs: string[]): void {
109
114
  for (const db of dbs) {
115
+ if (db === 'native-mariadb-data' && (existsSync(join(from.dirs.data, db, 'server.pid')) || existsSync(join(from.dirs.data, db, 'server.sock'))))
116
+ throw new Error(`volter-world branch: ${from.name}'s managed MariaDB retains PID/socket files; verified down is required`);
117
+ if (db === 'native-mariadb-data') {
118
+ const stamp = join(from.dirs.data, db, '.world-policy.json');
119
+ if (!existsSync(stamp) || !lstatSync(stamp).isFile() || existsSync(join(from.dirs.data, db, '.world-initializing.json')))
120
+ throw new Error(`volter-world branch: ${from.name}'s MariaDB has incomplete initialization; original data retained`);
121
+ }
110
122
  for (const pidFile of hostPidFiles(from.dirs.data, db)) {
123
+ // Successful managed-twin down removes both receipts. A partial publication or an
124
+ // uncertain retirement must finish there before branch copies this store.
125
+ if (db === 'clickhouse-twin' || db === 'native-mariadb-data') {
126
+ throw new Error(`volter-world branch: ${from.name}'s managed ${db} retains ownership receipts; stop ${from.name} before copying its store`);
127
+ }
111
128
  const recorded = existsSync(pidFile) ? readFileSync(pidFile, 'utf8').trim() : '';
112
129
  const pid = db === 'native-postgres-data' && recorded ? JSON.parse(recorded).pid : Number(recorded);
113
130
  let live = false;
@@ -121,6 +138,7 @@ function refuseLiveDatabases(from: WorldInstance, dbs: string[]): void {
121
138
  function requireMatchingBackings(source: string[], child: string[]): void {
122
139
  const kinds = {
123
140
  postgres: ['native-postgres-data', 'pglite-postgres-data'],
141
+ mysql: ['native-mariadb-data'],
124
142
  mongodb: ['mongod-mongodb-data'],
125
143
  };
126
144
  for (const [kind, formats] of Object.entries(kinds)) {
@@ -158,15 +176,15 @@ async function branchManagedDatabases(from: WorldInstance, instance: WorldInstan
158
176
  const infrastructure = config.services.find((service) => service.type === 'external' && (service.external?.up[0] === 'volter-world-infra' || service.id === 'infrastructure'));
159
177
  if (!infrastructure) throw new Error(`volter-world branch: ${instance.name}'s managed databases have no declared infrastructure service`);
160
178
  const launch = { env: externalServiceEnv(infrastructure, instance.dirs, instance.env, config.stripEnv) };
161
- if (dbs.includes('native-postgres-data')) {
179
+ if (dbs.some(directory => ['native-postgres-data', 'native-mariadb-data', 'clickhouse-twin'].includes(directory))) {
162
180
  const io = { out: (text: string) => process.stdout.write(text), err: (text: string) => process.stderr.write(text) };
163
- if (await runInfrastructure('down', launch.env, io) !== 0) throw new Error('managed native database branch could not stop its new backing');
181
+ if (await runInfrastructure('down', launch.env, io) !== 0) throw new Error('managed database branch could not stop its new backing');
164
182
  for (const db of dbs) {
165
183
  const to = join(instance.dirs.data, db);
166
184
  rmSync(to, { recursive: true, force: true });
167
185
  cpSync(join(from.dirs.data, db), to, { recursive: true });
168
186
  }
169
- if (await runInfrastructure('up', launch.env, io) !== 0) throw new Error('managed native database branch could not resume its copied backing');
187
+ if (await runInfrastructure('up', launch.env, io) !== 0) throw new Error('managed database branch could not resume its copied backing');
170
188
  return;
171
189
  }
172
190
  const redisCli = dbs.includes('redis-twin') && redis.length ? redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(instance.configPath)]) : undefined;
@@ -0,0 +1,15 @@
1
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime-state';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /** Instance bookkeeping, alongside the clock; retained at down, discarded at reset. */
5
+ export const browsersDir = (data: string): string => join(dirname(data), 'browser-records');
6
+
7
+ /** Empty Worlds need no browser validators or cookie/load machinery. A prior
8
+ * projection still loads the ordinary materializer to remove stale files. */
9
+ export async function materializeWorldBrowserFiles(data: string, root: string, name: string, main = name): Promise<void> {
10
+ const store = getActiveWorldStore();
11
+ if (!store.list(browsersDir(data)).some(file => file.endsWith('.json'))
12
+ && store.read(join(root, stateDirName(), 'browsers', '.source.json')) === null) return;
13
+ const { materializeBrowserFiles } = await import('./browsers.ts');
14
+ materializeBrowserFiles(data, root, name, main);
15
+ }
package/src/browsers.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { assertBrowserName, validateBrowserState, type BrowserState } from '@volter/world-core';
2
- import { getActiveWorldStore, stateDirName, type WorldStore } from '@volter/world-core/runtime';
1
+ import { assertBrowserName, validateBrowserState, type BrowserState } from '@volter/world-core/browser-state';
2
+ import { getActiveWorldStore, stateDirName, type WorldStore } from '@volter/world-core/runtime-state';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { dirname, join } from 'node:path';
5
5
 
6
6
  export type WorldBrowser = { name: string; state: BrowserState; version: string };
7
- /** Instance bookkeeping, alongside the clock; retained at down, discarded at reset. */
8
- export const browsersDir = (data: string): string => join(dirname(data), 'browser-records');
7
+ import { browsersDir } from './browser-files.ts';
8
+ export { browsersDir } from './browser-files.ts';
9
9
  const path = (data: string, name: string): string => { assertBrowserName(name); return join(browsersDir(data), `${name}.json`); };
10
10
  const records = new WeakMap<WorldStore, Map<string, { version: string; text: string; record: WorldBrowser }>>();
11
11
  export function worldBrowser(data: string, name: string): WorldBrowser | null {
package/src/catalog.ts CHANGED
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { overlayPackFacts, type PackFacts } from './pack-facts.ts';
12
- import { installedPackageDir, installedVendorPacks, isDerivedPack, packRepository } from '@volter/world-core/runtime';
12
+ import { installedPackageDir, installedVendorPacks, isDerivedPack, packRepository } from '@volter/world-core/runtime-state';
13
13
 
14
14
  export const TWIN_PACKAGE_PREFIX = '@volter/twin-';
15
15
 
@@ -156,7 +156,7 @@ export function packFactsFile(dir: string): string | undefined {
156
156
 
157
157
  /** The catalog for a World root: its pack repository (or the one beside its platform checkout), merged with installed
158
158
  * packages. Only the selected packs' own facts are overlaid on the kernel's. */
159
- export function resolveCatalog(root: string, options: { sources?: Readonly<Record<string, string>>; vendors?: readonly string[] } = {}): Catalog {
159
+ export function resolveCatalog(root: string, options: { sources?: Readonly<Record<string, string>>; vendors?: readonly string[]; mayBeEmpty?: boolean } = {}): Catalog {
160
160
  const resolved = resolve(root);
161
161
  const installed = installedCatalog(resolved, options);
162
162
  const repo = packRepository(resolved);
@@ -165,7 +165,8 @@ export function resolveCatalog(root: string, options: { sources?: Readonly<Recor
165
165
  const file = packFactsFile(catalog.packDir(vendor)!);
166
166
  if (file) overlayPackFactsFile(file);
167
167
  }
168
- if (catalog.vendors().length === 0) {
168
+ // A World whose application runs beside infrastructure alone has no twin to find (`init --needs`, architecture).
169
+ if (catalog.vendors().length === 0 && !options.mayBeEmpty) {
169
170
  throw new Error(
170
171
  `No twins found for ${resolved}: no pack repository and no \`@volter/twin-<vendor>\` package installed above it. `
171
172
  + `Install the twins your app needs (e.g. \`bun add -d @volter/twin-stripe\`) and run init again.`,
package/src/changeset.ts CHANGED
@@ -641,7 +641,7 @@ export function rebaseWorldChangeset(name: string, options: WorldRootOptions & {
641
641
  const moved = [...new Set(located.changeset.actions.map((e) => e.service))].map((service) => ({ st: located.changeset.actions.find((e) => e.service === service)?.stateService ?? service, controlRoot: join(inst.dirs.data, service) }));
642
642
  withHistoryLocks(moved.map(({ st, controlRoot }) => worldPaths(st, controlRoot).dir), () => {
643
643
  for (const { st, controlRoot } of moved) rebaseBranch(st, controlRoot, { origin: Boolean(readBranchMeta(st, controlRoot)?.origin) });
644
- });
644
+ }, { operation: 'branch-pointer adjustment' });
645
645
  if (!changesetHashMatches(located.changeset)) throw new Error(`Refusing to rebase changeset "${name}": its stored contentHash does not match its body — the object drifted after authoring`);
646
646
  const instance = requireWorld(located.world, root);
647
647
  const targets: RebaseTarget[] = [...new Set(located.changeset.actions.map((entry) => entry.service))].map((service) => ({ service, stateService: located.changeset.actions.find((entry) => entry.service === service)!.stateService, controlRoot: join(instance.dirs.data, service) }));