@volter/world-runtime 3.0.163 → 3.0.165

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 (137) 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 +9 -3
  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 +446 -19
  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 +3 -3
  81. package/dist/src/twin-backing.d.ts +47 -0
  82. package/dist/src/twin-backing.js +343 -0
  83. package/dist/src/up-cli.js +56 -3
  84. package/package.json +3 -3
  85. package/src/ancestry.ts +2 -2
  86. package/src/app-url.ts +9 -2
  87. package/src/branch.ts +26 -8
  88. package/src/browser-files.ts +15 -0
  89. package/src/browsers.ts +4 -4
  90. package/src/catalog.ts +9 -4
  91. package/src/changeset.ts +1 -1
  92. package/src/cli-commands.ts +85 -18
  93. package/src/cli-load-clock.ts +2 -0
  94. package/src/cli.ts +5 -1
  95. package/src/clickhouse-backing.ts +70 -0
  96. package/src/command-lifetime.d.mts +1 -1
  97. package/src/command-lifetime.mjs +5 -2
  98. package/src/configs.ts +1 -1
  99. package/src/consumers.ts +1 -1
  100. package/src/covers.ts +9 -4
  101. package/src/go-mysql-binding.ts +49 -0
  102. package/src/guest-processes.d.mts +1 -1
  103. package/src/guest-processes.mjs +22 -14
  104. package/src/host-cli.ts +7 -1
  105. package/src/host-ready.ts +62 -31
  106. package/src/host-worker.ts +6 -2
  107. package/src/host.ts +88 -6
  108. package/src/infra-definition.ts +135 -0
  109. package/src/infra.ts +250 -45
  110. package/src/init.ts +417 -18
  111. package/src/lifecycle-record.ts +18 -4
  112. package/src/local-branches.ts +1 -1
  113. package/src/mariadb-bootstrap.ts +107 -0
  114. package/src/mariadb-supplier.ts +199 -0
  115. package/src/mongodb-backing.ts +59 -1
  116. package/src/native-mariadb-backing.ts +418 -0
  117. package/src/native-postgres-backing.ts +353 -41
  118. package/src/native-server-inputs.ts +169 -0
  119. package/src/origin.ts +3 -3
  120. package/src/original-inputs.ts +72 -0
  121. package/src/pack-facts.ts +1 -1
  122. package/src/pglite-backing.ts +192 -80
  123. package/src/pglite-host.mjs +482 -10
  124. package/src/process-groups.d.mts +6 -1
  125. package/src/process-groups.mjs +82 -15
  126. package/src/process-groups.ts +2 -1
  127. package/src/process-signal.d.mts +2 -2
  128. package/src/process-signal.mjs +29 -9
  129. package/src/project-inspect.ts +37 -4
  130. package/src/proxy-env.ts +128 -0
  131. package/src/redirect-proxy.ts +3 -125
  132. package/src/redis-backing.ts +19 -136
  133. package/src/runtime.ts +342 -107
  134. package/src/schema.ts +5 -4
  135. package/src/served-world.ts +3 -3
  136. package/src/twin-backing.ts +300 -0
  137. package/src/up-cli.ts +52 -3
package/src/init.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { copyNativeServerInputs, readNativeServerInputs, nativeServerRuntimeReference, type NativeServerInputs } from './native-server-inputs.ts';
2
+ import { goMysqlBinding, renderGoMysqlBinding, type GoMysqlBinding } from './go-mysql-binding.ts';
3
+ import * as managedOriginalInputs from '@volter/world-core/managed-original-inputs';
4
+ import { copyOriginalInputSnapshot, readOriginalInputSnapshot, type OriginalInputReference } from './original-inputs.ts';
1
5
  // `volter-world init <name> --repo <path>` — the DETERMINISTIC FRONT DOOR of world adoption.
2
6
  //
3
7
  // Everything it needs already existed, in three pieces that no one command composed:
@@ -33,6 +37,7 @@
33
37
  // with a remediation line each. App-boot risks are a separate, prominent signal: an empty
34
38
  // example value read by production source can block before listen even when vendor coverage is
35
39
  // green. `init` never boots the world: it diagnoses, the operator (or the room) boots.
40
+ import { createHash } from 'node:crypto';
36
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
37
42
  import { PROTOCOL_MAJOR, stateDirName } from '@volter/world-core/runtime';
38
43
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -51,7 +56,7 @@ import {
51
56
  import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
52
57
  import { findInstalledPackage, packCli, packEntry, resolveCatalog, type Catalog } from './catalog.ts';
53
58
  import { CATALOG_INDEX_PACKAGE, defaults as catalogDefaults } from './catalog-index.ts';
54
- import { projectEnvReads, projectManifestDirs, projectMigrationTools, type ProjectMigrationTool } from './project-inspect.ts';
59
+ import { filesLeftAsThePersonsOwn, projectEnvReads, projectManifestDirs, projectMigrationTools, readOnlyWhatTheSourceHolds, sourceText, type ProjectMigrationTool } from './project-inspect.ts';
55
60
  import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
56
61
  import { VENDOR_SERVER_BACKINGS } from './backings.ts';
57
62
  import { APP_URL_ENV, DEFAULT_WORLD_SELECTION, ISSUE_ENV_PREFIX, MINT_ENV, worldConfigDocument, worldSelectionIncludes, type WorldConfig, type WorldSelection, type WorldServiceConfig } from './schema.ts';
@@ -173,9 +178,20 @@ export type InitInfraStub = {
173
178
  export type InitComposeService = {
174
179
  kind: string;
175
180
  image: string;
181
+ /** An explicit MongoDB connection mode, also carried to the selected backing. */
182
+ replicaSet?: string;
176
183
  /** deterministic host port, derived from the world name (see composePort) */
177
184
  hostPort: number;
178
185
  containerPort: number;
186
+ virtualTcpEndpoint?: { version: 1; host: string; port: number; targetPort: number };
187
+ connectionBindings?: Record<string, GoMysqlBinding & { containerPort: number; query?: string }>;
188
+ sides?: Record<string, { hostPort: number; containerPort: number }>;
189
+ configuration?: Record<string, unknown>;
190
+ originalInputs?: OriginalInputReference;
191
+ originalInputsFrom?: string;
192
+ nativeServerInputs?: NativeServerInputs;
193
+ nativeServerInputsFrom?: string;
194
+ nativeCredentialAliases?: string[];
179
195
  memoryMiB: number;
180
196
  /** compose volume key, or null (the on-disk docker volume is `<project>_<key>` — the compose
181
197
  * project name carries the world name, so volumes are named per world) */
@@ -208,6 +224,12 @@ export type InitPlan = {
208
224
  * (runtime.database.prepare): reported, never written, and no part of the vendor-coverage verdict */
209
225
  undeclaredMigrations: ProjectMigrationTool[];
210
226
  infra: InitInfraStub[];
227
+ /** each service of the project's own stack and what was made of it, where `--needs` gave the stack */
228
+ stackNeeds?: InitStackNeed[];
229
+ /** The same declared application names used by selection and its coverage proof. */
230
+ stackEnvironmentNames?: string[];
231
+ /** the project files a World made for an image left unread: the source does not hold them, so they are the person's own */
232
+ leftAsThePersonsOwn?: string[];
211
233
  /** the services emitted into the private managed-infrastructure definition */
212
234
  compose: InitComposeService[];
213
235
  /** private managed-infrastructure definition, or null when no detected infra has a recipe */
@@ -271,8 +293,104 @@ export type InitOptions = {
271
293
  sources?: Record<string, string>;
272
294
  /** A world with no app: served as `/<org>/<world>/`. */
273
295
  bare?: string;
296
+ /** The project's own stack as its image builder read it (`needs.json`): architecture, "The project's own stack as evidence". */
297
+ needs?: string;
274
298
  };
275
299
 
300
+ /** One service of the project's stack, as `init` took it. */
301
+ export type InitStackNeed = {
302
+ service: string;
303
+ image?: string;
304
+ family?: string;
305
+ /** the managed kind it is, or null where the World has none for it */
306
+ kind: string | null;
307
+ /** the application's variables given this service's address */
308
+ names: string[];
309
+ /** true where the stack named no variable and the conventional names were supplied */
310
+ conventional: boolean;
311
+ note: string;
312
+ };
313
+
314
+ /** The families the image builder names (its SERVICE_FAMILIES) that are a managed kind here. */
315
+ const FAMILY_KIND: Record<string, string> = { postgres: 'postgres', mysql: 'mysql', mongodb: 'mongodb', redis: 'redis', search: 'search', clickhouse: 'clickhouse', memcached: 'memcached' };
316
+ const CONVENTIONAL_NAMES: Record<string, string[]> = { postgres: ['DATABASE_URL'], mysql: ['DATABASE_URL'], mongodb: ['MONGODB_URI'], redis: ['REDIS_URL'], clickhouse: ['CLICKHOUSE_URL'] };
317
+
318
+ type StackConnectionComponent = "database" | "user" | "password";
319
+
320
+
321
+ type StackNeedsFile = { kind?: unknown; version?: unknown; composeFile?: unknown; application?: { service?: unknown }; settings?: unknown; unset?: unknown; needs?: Array<{ service?: unknown; image?: unknown; family?: unknown; supplier?: unknown; names?: Array<{ name?: unknown; scheme?: unknown; query?: unknown; withPort?: unknown; containerPort?: unknown; dsnFormat?: unknown; endpoint?: unknown; dsnPrefix?: unknown; dsnProtocol?: unknown }>; ports?: unknown; portTargets?: unknown; components?: unknown; originalInputs?: unknown; nativeServerInputs?: unknown }> };
322
+
323
+ /** The needs file, checked for its kind: a file of another shape is refused by name, never half-read. */
324
+ function readStackNeeds(path: string): { composeFile: string; application?: string; settings: Array<{ name: string; value: string }>; unset: string[]; needs: Array<{ service: string; image?: string; family?: string; names: Array<{ name: string; scheme?: string; query?: string; withPort?: true; containerPort?: number } & Partial<GoMysqlBinding>>; originalInputs?: OriginalInputReference; nativeServerInputs?: NativeServerInputs; nativeCredentialAliases?: string[]; ports: string[]; portTargets: Record<string, number>; components: Array<{ name: string; component: StackConnectionComponent }> }> } {
325
+ let document: StackNeedsFile;
326
+ try { document = JSON.parse(readFileSync(path, 'utf8')) as StackNeedsFile; }
327
+ catch (error) { throw new Error(`volter init: --needs ${path} could not be read as JSON: ${error instanceof Error ? error.message : String(error)}`); }
328
+ if (document.kind !== 'browser-substrate.stack-needs' || (document.version !== 1 && document.version !== 2 && document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Array.isArray(document.needs)) {
329
+ throw new Error(`volter init: --needs ${path} is not a stack-needs file (kind "browser-substrate.stack-needs", version 1, 2, 3, 4, 5, 6 or 7)`);
330
+ }
331
+ const identifier = (name: string, what: string): string => {
332
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`volter init: --needs ${path} names a variable that is not an identifier among ${what}: ${JSON.stringify(name)}`);
333
+ return name;
334
+ };
335
+ const record = (value: unknown): Array<[string, unknown]> => value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [];
336
+ return {
337
+ composeFile: typeof document.composeFile === 'string' ? document.composeFile : 'the project\'s Compose file',
338
+ ...(typeof document.application?.service === 'string' ? { application: document.application.service } : {}),
339
+ // The application's own variables that name no service: its settings as the stack writes them, and those whose
340
+ // value the stack makes from a variable nothing sets, which are named and have no value here.
341
+ settings: record(document.settings).filter(([, value]) => typeof value === 'string').map(([name, value]) => ({ name: identifier(name, 'the application\'s settings'), value: value as string })),
342
+ unset: record(document.unset).map(([name]) => identifier(name, 'the application\'s unset variables')),
343
+ needs: document.needs.filter((need) => typeof need.service === 'string').map((need) => ({
344
+ service: need.service as string,
345
+ ...(need.originalInputs === undefined ? {} : { originalInputs: (() => {
346
+ if (document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) throw new Error('volter init: original inputs require needs version4,5,6 or7');
347
+ const reference = managedOriginalInputs.referenceOf(need.originalInputs);
348
+ const source = readOriginalInputSnapshot(dirname(resolve(path)), reference).snapshot.manifest;
349
+ const image = source.start.service.image as { raw?: unknown } | undefined;
350
+ if (source.service !== need.service || source.family !== need.family || typeof need.image !== 'string' || image?.raw !== need.image) throw new Error('volter init: original inputs belong to another declared service');
351
+ return reference;
352
+ })() }),
353
+ ...(need.nativeServerInputs === undefined ? {} : (() => {
354
+ if (document.version !== 5 && document.version !== 7) throw new Error('volter init: native managed input requires needs5 or7');
355
+ const checked = readNativeServerInputs(dirname(resolve(path)), need.nativeServerInputs);
356
+ const source = JSON.parse(checked.files.get('declaration.json')!.toString('utf8')) as { name?: unknown; image?: { raw?: unknown } };
357
+ if (need.family !== 'mysql' || need.supplier !== 'a World' || source.name !== need.service || source.image?.raw !== need.image)
358
+ throw new Error('volter init: native managed source belongs to another declared service');
359
+ return { nativeServerInputs: checked.reference, nativeCredentialAliases: checked.aliases };
360
+ })()),
361
+ ...(typeof need.image === 'string' ? { image: need.image } : {}),
362
+ ...(typeof need.family === 'string' ? { family: need.family } : {}),
363
+ names: (Array.isArray(need.names) ? need.names : []).map((entry) => {
364
+ // A name becomes a key of the World's environment file: an identifier, or it is refused by name. Anything
365
+ // else (a newline, an `=`) would write lines of its own there.
366
+ if (typeof entry.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name)) throw new Error(`volter init: --needs ${path} names a variable that is not an identifier under service ${String(need.service)}: ${JSON.stringify(entry.name)}`);
367
+ if (entry.containerPort !== undefined && ((document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535)) throw new Error(`volter init: --needs ${path} needs version3 and a valid declared vendor port`);
368
+ return { name: entry.name, ...goMysqlBinding(entry, document.version, entry.name, COMPOSE_INFRA.mysql!.containerPort), ...(entry.containerPort !== undefined ? { containerPort: Number(entry.containerPort) } : {}), ...(typeof entry.scheme === 'string' && /^[a-z][a-z0-9+.-]*$/i.test(entry.scheme) ? { scheme: entry.scheme } : {}), ...(typeof entry.query === 'string' ? { query: entry.query } : {}), ...(entry.withPort === true ? { withPort: true as const } : {}) };
369
+ }),
370
+ components: (() => {
371
+ if (need.components === undefined) return [];
372
+ if ((document.version !== 2 && document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !Array.isArray(need.components)) throw new Error(`volter init: --needs ${path} requires version 2,3,4,5,6 or7 for store components`);
373
+ return need.components.map((part: unknown) => {
374
+ if (!part || typeof part !== 'object' || Array.isArray(part)) throw new Error(`volter init: --needs ${path} has an invalid store component`);
375
+ const field = part as { name?: unknown; component?: unknown };
376
+ if (typeof field.name !== 'string' || !['database', 'user', 'password'].includes(String(field.component))) throw new Error(`volter init: --needs ${path} has an invalid store component`);
377
+ return { name: identifier(field.name, `the components of service ${String(need.service)}`), component: field.component as StackConnectionComponent };
378
+ });
379
+ })(),
380
+ // The variables that hold the service's port beside one that holds its host: keys of the environment file too.
381
+ ports: (Array.isArray(need.ports) ? need.ports as unknown[] : []).map((name) => identifier(String(name), `the port variables of service ${String(need.service)}`)),
382
+ portTargets: (() => {
383
+ if (need.portTargets === undefined) return {};
384
+ if ((document.version !== 3 && document.version !== 4 && document.version !== 5 && document.version !== 6 && document.version !== 7) || !need.portTargets || typeof need.portTargets !== 'object' || Array.isArray(need.portTargets)) throw new Error(`volter init: --needs ${path} needs version3 for named vendor ports`);
385
+ return Object.fromEntries(Object.entries(need.portTargets).map(([name, value]) => {
386
+ if (!Array.isArray(need.ports) || !need.ports.includes(name) || !Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(`volter init: --needs ${path} has an invalid named vendor port`);
387
+ return [identifier(name, 'named vendor ports'), Number(value)];
388
+ }));
389
+ })(),
390
+ })),
391
+ };
392
+ }
393
+
276
394
  // ---------------------------------------------------------------------------------------------
277
395
  // The pack catalog
278
396
  // ---------------------------------------------------------------------------------------------
@@ -485,6 +603,14 @@ const COMPOSE_INFRA: Record<string, {
485
603
  /** healthcheck exec-form argv (makes `docker compose up -d --wait` meaningful) */
486
604
  healthcheck: (user: string) => string[];
487
605
  }> = {
606
+ clickhouse: {
607
+ image: 'clickhouse/clickhouse-server:26.9.13.15',
608
+ containerPort: 8123,
609
+ // The existing managed-database Docker default; a backend limit, not measured adequacy.
610
+ memoryMiB: 1024,
611
+ volumePath: '/var/lib/clickhouse',
612
+ healthcheck: () => ['CMD', 'wget', '--quiet', '--output-document=-', 'http://127.0.0.1:8123/ping'],
613
+ },
488
614
  postgres: {
489
615
  image: 'postgres:16',
490
616
  containerPort: 5432,
@@ -506,8 +632,8 @@ const COMPOSE_INFRA: Record<string, {
506
632
  volumePath: '/data',
507
633
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
508
634
  },
509
- // The runtime serves this kind with MongoDB's own mongod on PATH, without a container,
510
- // at the same loopback port and URL; command: ["mongod", "--replSet", "world"] enables transactions.
635
+ // The installed declaring twin, or MongoDB's own server, owns this kind's backing.
636
+ // A replica-set name is metadata passed to that backing, never a choice of executable.
511
637
  mongodb: {
512
638
  image: 'mongo:7',
513
639
  containerPort: 27017,
@@ -564,11 +690,14 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
564
690
  // redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
565
691
  const url = kind === 'redis'
566
692
  ? `redis://127.0.0.1:${hostPort}`
567
- : kind === 'mongodb'
693
+ : kind === 'clickhouse'
694
+ ? `http://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`
695
+ : kind === 'mongodb'
568
696
  ? `mongodb://127.0.0.1:${hostPort}/${ident}`
569
697
  : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
570
698
  return {
571
699
  kind,
700
+ ...(kind === 'clickhouse' ? { sides: { native: { hostPort: composePort(name, 'clickhouse-native', taken), containerPort: 9000 } }, configuration: { users: { default: '', [ident]: ident }, databases: [ident] } } : {}),
572
701
  image: recipe.image,
573
702
  hostPort,
574
703
  containerPort: recipe.containerPort,
@@ -585,12 +714,20 @@ function composeEnvironment(name: string, kind: string): Array<[string, string]>
585
714
  if (kind === 'postgres') {
586
715
  return [['POSTGRES_USER', ident], ['POSTGRES_PASSWORD', ident], ['POSTGRES_DB', ident]];
587
716
  }
717
+ if (kind === 'clickhouse') return [['CLICKHOUSE_DB', ident], ['CLICKHOUSE_USER', ident], ['CLICKHOUSE_PASSWORD', ident], ['CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT', '1']];
588
718
  if (kind === 'mysql') {
589
719
  return [['MYSQL_ROOT_PASSWORD', ident], ['MYSQL_DATABASE', ident], ['MYSQL_USER', composeUser(name, kind)], ['MYSQL_PASSWORD', ident]];
590
720
  }
591
721
  return [];
592
722
  }
593
723
 
724
+ function managedPort(service: InitComposeService, containerPort?: number): number {
725
+ if (containerPort === undefined || containerPort === service.containerPort) return service.hostPort;
726
+ const side = Object.values(service.sides ?? {}).find(port => port.containerPort === containerPort);
727
+ if (!side) throw new Error(`volter init: managed ${service.kind} does not serve vendor port ${containerPort}`);
728
+ return side.hostPort;
729
+ }
730
+
594
731
  /** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
595
732
  * Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
596
733
  export function renderComposeFile(plan: InitPlan): string {
@@ -604,13 +741,42 @@ export function renderComposeFile(plan: InitPlan): string {
604
741
  for (const service of plan.compose) {
605
742
  const recipe = COMPOSE_INFRA[service.kind]!;
606
743
  lines.push(` ${service.kind}:`);
607
- lines.push(` image: ${service.image}`);
744
+ lines.push(` image: ${JSON.stringify(service.image)}`);
745
+ if (service.virtualTcpEndpoint) lines.push(` x-volter-virtual-tcp-endpoint: ${JSON.stringify(service.virtualTcpEndpoint)}`);
746
+ if (service.replicaSet !== undefined) lines.push(` x-volter-replica-set: ${JSON.stringify(service.replicaSet)}`);
747
+ if (service.nativeServerInputs) {
748
+ lines.push(' x-volter-interfaces: 3');
749
+ lines.push(` x-volter-native-server: ${JSON.stringify(nativeServerRuntimeReference(service.nativeServerInputs))}`);
750
+ }
751
+ if (service.originalInputs) {
752
+ if (!service.sides) lines.push(' x-volter-interfaces: 2');
753
+ lines.push(` x-volter-original-inputs: ${JSON.stringify({ ...service.originalInputs, root: `original-inputs/${service.originalInputs.sha256}` })}`);
754
+ }
608
755
  lines.push(` mem_limit: ${service.memoryMiB}m`);
609
756
  lines.push(` memswap_limit: ${service.memoryMiB}m`);
610
757
  lines.push(' cpus: 1');
758
+ if (service.connectionBindings) {
759
+ lines.push(' x-volter-issued-dsn-bindings:');
760
+ for (const [name, binding] of Object.entries(service.connectionBindings)) {
761
+ lines.push(` ${name}:`);
762
+ for (const [field, value] of Object.entries(binding)) lines.push(` ${field}: ${JSON.stringify(value)}`);
763
+ }
764
+ }
611
765
  lines.push(' ports:');
612
766
  lines.push(` - "127.0.0.1:${service.hostPort}:${service.containerPort}"`);
767
+ for (const side of Object.values(service.sides ?? {})) lines.push(` - "127.0.0.1:${side.hostPort}:${side.containerPort}"`);
768
+ if (service.sides) {
769
+ lines.push(` x-volter-interfaces: ${service.originalInputs ? 2 : 1}`);
770
+ lines.push(` x-volter-side-ports: ${JSON.stringify(service.sides)}`);
771
+ lines.push(` x-volter-configuration: ${JSON.stringify(service.configuration)}`);
772
+ }
613
773
  const environment = composeEnvironment(plan.name, service.kind);
774
+ if (service.nativeServerInputs) for (const alias of service.nativeCredentialAliases ?? []) {
775
+ const canonical = alias.replace(/^MARIADB_/, 'MYSQL_');
776
+ const entry = environment.find(([name]) => name === canonical);
777
+ if (!entry) throw new Error('volter init: native provider credential alias has no issued field');
778
+ environment.push([alias, entry[1]]);
779
+ }
614
780
  if (environment.length > 0) {
615
781
  lines.push(' environment:');
616
782
  for (const [key, value] of environment) lines.push(` ${key}: ${value}`);
@@ -668,8 +834,10 @@ function repoEnvEntries(repo: string): ParsedEnvEntry[] {
668
834
  for (const file of ENV_SOURCE_FILES) {
669
835
  const path = resolve(dir, file);
670
836
  if (!existsSync(path)) continue;
837
+ const text = sourceText(path);
838
+ if (text === undefined) continue;
671
839
  const source = relative(repo, path) || file;
672
- for (const entry of parseEnvFile(readFileSync(path, 'utf8'), source)) {
840
+ for (const entry of parseEnvFile(text, source)) {
673
841
  if (inDir.has(entry.name)) continue;
674
842
  inDir.add(entry.name);
675
843
  byName.set(entry.name, [...(byName.get(entry.name) ?? []), { ...entry, dir }]);
@@ -707,7 +875,11 @@ function isInside(parent: string, candidate: string): boolean {
707
875
 
708
876
  export function planWorldInit(name: string, repoPath: string, options: InitOptions = {}): InitPlan {
709
877
  assertSafeName(name);
878
+ // The World's own name, kept under a name no loop below takes: what it issues is made from it.
879
+ const worldName = name;
710
880
  const repo = resolve(repoPath);
881
+ // A World made from a stack's needs is made for an image: only what the project's source holds is read.
882
+ readOnlyWhatTheSourceHolds(options.needs ? repo : undefined);
711
883
  if (!existsSync(repo)) throw new Error(`volter init: the application repo does not exist: ${repo}`);
712
884
  const root = resolve(options.root ?? repo);
713
885
  // The world lives in the app repo: `.volter/world.json` beside the code, committed like a
@@ -729,16 +901,18 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
729
901
  * (portable between teammates), absolute otherwise (a pilot emitted away from its root) */
730
902
  const carried = (path: string): string => (isInside(root, path) ? relative(root, path) : path);
731
903
 
732
- const catalogSource: Catalog = resolveCatalog(root, { sources: options.sources, vendors: options.vendors });
904
+ const catalogSource: Catalog = resolveCatalog(root, { sources: options.sources, vendors: options.vendors, mayBeEmpty: options.needs !== undefined });
733
905
  const catalog = new Set(catalogSource.vendors());
734
906
  // the catalog index's defaults (the installed `@volter/twin-catalog`), which name the twin to install for a vendor
735
907
  // this catalog lacks
736
- const indexDir = findInstalledPackage(root, CATALOG_INDEX_PACKAGE);
908
+ const indexDir = options.vendors?.length === 0 ? undefined : findInstalledPackage(root, CATALOG_INDEX_PACKAGE);
737
909
  // A pack's starters, copied VISIBLY into the world dir: the author's to edit or delete (copy = ownership; never
738
910
  // baked into the pack). Its handlers starter is the answering turns its own life scripts (journeys/scenario.json, the
739
911
  // World's handler format, its faults left out); its default data is src/semantics/seed.ts.
740
912
  const defaultsCopies: InitPlan['defaultsCopies'] = [];
741
- const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services);
913
+ const stack = options.needs ? readStackNeeds(resolve(options.needs)) : undefined;
914
+ const stackEnvironmentNames = stack ? [...new Set([...stack.settings.map(entry => entry.name), ...stack.unset])].sort() : [];
915
+ const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services, stackEnvironmentNames);
742
916
  const detected = options.vendors
743
917
  ? new Map<string, string[]>(options.vendors.map((v) => [v, ['--twins']]))
744
918
  : new Map([...signals!.detected].filter(([vendor]) => (signals!.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
@@ -889,7 +1063,31 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
889
1063
  const envReads = projectEnvReads(repo);
890
1064
  const mintedEnv: string[] = [];
891
1065
  const infraSignals = new Map<string, string[]>();
892
- const infraEntries: Array<{ name: string; source: string; kind: string }> = [];
1066
+ const infraEntries: Array<{ name: string; source: string; kind: string; query?: string; host?: true; withPort?: true; scheme?: string; containerPort?: number } & Partial<GoMysqlBinding>> = [];
1067
+ // The project's own stack, where its image builder read it: each variable it wires to a service of a managed kind
1068
+ // is that kind's signal whatever the variable is called, and is decided here before a name's pattern is asked.
1069
+ const stackNames = new Map<string, { kind: string; source: string; host: boolean; withPort: boolean; query?: string; scheme?: string; containerPort?: number; service: string; conventional: boolean } & Partial<GoMysqlBinding>>();
1070
+ const stackNeeds: InitStackNeed[] = [];
1071
+ /** The names actually given each service's address, filled where an entry is written: the report counts these. */
1072
+ const stackWritten = new Map<string, string[]>();
1073
+ const kinds = (stack?.needs ?? []).map((need) => {
1074
+ const fromScheme = need.names.map((entry) => entry.scheme === undefined ? null : schemeKindFor(`${entry.scheme}://`)).find((kind) => kind !== null) ?? null;
1075
+ return { need, fromScheme, kind: fromScheme ?? (need.family !== undefined ? FAMILY_KIND[need.family] ?? null : null), source: `${stack!.composeFile} service ${need.service}` };
1076
+ });
1077
+ // Explicit names first, over the whole stack: a service the stack wires by name keeps its names whatever is
1078
+ // listed before it. Conventional names are for what is left, and never one another service was given.
1079
+ for (const { need, kind, source } of kinds) {
1080
+ if (kind === null) continue;
1081
+ for (const entry of need.names) if (!stackNames.has(entry.name)) stackNames.set(entry.name, { kind, source, ...(entry.dsnFormat ? { dsnFormat: entry.dsnFormat, endpoint: entry.endpoint, dsnPrefix: entry.dsnPrefix, dsnProtocol: entry.dsnProtocol } : {}), host: entry.scheme === undefined, withPort: entry.withPort === true, ...(entry.scheme ? { scheme: entry.scheme } : {}), ...(entry.containerPort !== undefined ? { containerPort: entry.containerPort } : {}), ...(entry.query !== undefined ? { query: entry.query } : {}), service: need.service, conventional: false });
1082
+ }
1083
+ for (const { need, kind, source } of kinds) {
1084
+ if (kind === null || need.names.length > 0) continue;
1085
+ for (const name of CONVENTIONAL_NAMES[kind] ?? []) if (!stackNames.has(name)) stackNames.set(name, { kind, source, host: false, withPort: false, service: need.service, conventional: true });
1086
+ }
1087
+ const writeStackName = (name: string): void => {
1088
+ const wired = stackNames.get(name)!;
1089
+ stackWritten.set(wired.service, [...(stackWritten.get(wired.service) ?? []), name]);
1090
+ };
893
1091
  // SMTP's host and port under the names this app declares: apps name them their own way (twenty's EMAIL_SMTP_HOST/
894
1092
  // EMAIL_SMTP_PORT beside nodemailer's SMTP_HOST), and a raw-TCP twin is reached only through the env the app reads,
895
1093
  // so every declared `…SMTP_HOST` / `…SMTP_PORT` points at the smtp twin (twenty lane: its mail went to the log). A World
@@ -914,6 +1112,15 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
914
1112
  ...(existing?.services ?? []),
915
1113
  ...services.filter((service) => !existingIds.has(service.id)),
916
1114
  ].flatMap(endpointNames));
1115
+ const stackComponents = new Map<string, { kind: string; source: string; service: string; component: StackConnectionComponent }>();
1116
+ const addressNames = new Set((stack?.needs ?? []).flatMap(need => [...need.names.map(entry => entry.name), ...need.ports]));
1117
+ for (const { need, kind, source } of kinds) for (const part of need.components) {
1118
+ if (kind === null || addressNames.has(part.name) || worldInjected.has(part.name)) throw new Error(`volter init: ${part.name} has no exclusive managed store component binding`);
1119
+ const held = stackComponents.get(part.name);
1120
+ if (held && (held.kind !== kind || held.service !== need.service || held.component !== part.component)) throw new Error(`volter init: ${part.name} has conflicting managed store component bindings`);
1121
+ stackComponents.set(part.name, { kind, source, service: need.service, component: part.component });
1122
+ if (!held) stackWritten.set(need.service, [...(stackWritten.get(need.service) ?? []), part.name]);
1123
+ }
917
1124
  // Every name a twin of this World issues (its pack's credentialDoor fill), by the twin: the pack names them, so a name
918
1125
  // is issued whatever its stem (Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
919
1126
  // that vendor's twin is in the World, and never when it is not.
@@ -922,7 +1129,18 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
922
1129
  for (const name of Object.keys(service.issues?.fill ?? {})) if (!issuedBy.has(name)) issuedBy.set(name, service.id);
923
1130
  }
924
1131
  const standInStems = new Set<string>();
925
- for (const entry of repoEnvEntries(repo)) {
1132
+ // THE STACK'S OWN STATEMENT OF THE APPLICATION'S ENVIRONMENT comes before the env files: the Compose file is what
1133
+ // the project runs, an example env file what it suggests. Each name goes through the rules below as a name from a
1134
+ // file does (a credential-shaped one is replaced by a fake or issued, never copied; an infrastructure one is a
1135
+ // signal), so an application with no env file is still given its settings, and a secret the stack leaves to a
1136
+ // variable nothing sets is one the World supplies.
1137
+ const stackSource = stack ? `${stack.composeFile}${stack.application ? ` service ${stack.application}` : ''} environment` : '';
1138
+ const stackEntries: ParsedEnvEntry[] = stack ? [
1139
+ ...stack.settings.map(({ name, value }) => ({ name, value, source: stackSource })),
1140
+ ...stack.unset.filter((name) => !stack.settings.some((entry) => entry.name === name)).map((name) => ({ name, value: '', source: `${stackSource} (left to a variable nothing sets)` })),
1141
+ ] : [];
1142
+ const stated = new Set(stackEntries.map((entry) => entry.name));
1143
+ for (const entry of [...stackEntries, ...repoEnvEntries(repo).filter((entry) => !stated.has(entry.name))]) {
926
1144
  if (!envSources.includes(entry.source)) envSources.push(entry.source);
927
1145
  const { name, value, source, conflict } = entry;
928
1146
  // World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
@@ -993,12 +1211,29 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
993
1211
  envRows.push({ name, disposition: 'faked', source, reason: `${vendor} credential` });
994
1212
  continue;
995
1213
  }
996
- const infraKind = infraKindFor(name, value);
1214
+ // A conventional name is the stack's guess; where the application's own example gives that name a value whose
1215
+ // scheme says another kind (`DATABASE_URL=mysql://…` beside an unnamed PostgreSQL), the value is the evidence.
1216
+ const offered = stackNames.get(name);
1217
+ const valueKind = schemeKindFor(value);
1218
+ const wired = offered !== undefined && offered.conventional && valueKind !== null && valueKind !== offered.kind ? undefined : offered;
1219
+ if (offered !== undefined && wired === undefined) stackNames.delete(name);
1220
+ const infraKind = wired?.kind ?? infraKindFor(name, value);
997
1221
  if (infraKind !== null) {
1222
+ if (wired) writeStackName(name);
1223
+ let query = wired?.query;
1224
+ if (query === undefined && infraKind === 'mongodb') {
1225
+ const authority = /^mongodb(?:\+srv)?:\/\/([^/?#]+)/u.exec(value)?.[1]?.split('@').at(-1);
1226
+ if (authority?.includes(',')) throw new Error(`volter init: ${name} names a multi-host MongoDB URI; one managed listener cannot retain that seed list`);
1227
+ try {
1228
+ const asked = new URL(value).searchParams.getAll('replicaSet');
1229
+ if (asked.length) query = new URLSearchParams(asked.map(set => ['replicaSet', set])).toString();
1230
+ }
1231
+ catch { /* a host or empty example declares no URI query */ }
1232
+ }
998
1233
  // DEFERRED: the value depends on whether this kind gets a compose service (a URL pointing
999
1234
  // at it) or stays a documented placeholder — decided below, once every signal is in.
1000
1235
  infraSignals.set(infraKind, [...(infraSignals.get(infraKind) ?? []), name]);
1001
- infraEntries.push({ name, source, kind: infraKind });
1236
+ infraEntries.push({ name, source: wired ? `${wired.source}; ${source}` : source, kind: infraKind, ...(wired?.dsnFormat ? { dsnFormat: wired!.dsnFormat, endpoint: wired!.endpoint, dsnPrefix: wired!.dsnPrefix, dsnProtocol: wired!.dsnProtocol } : {}), ...(wired?.scheme ? { scheme: wired.scheme } : {}), ...(wired?.containerPort !== undefined ? { containerPort: wired.containerPort } : {}), ...(query !== undefined ? { query } : {}), ...(wired?.host ? { host: true as const } : {}), ...(wired?.withPort ? { withPort: true as const } : {}) });
1002
1237
  continue;
1003
1238
  }
1004
1239
  // A detected vendor's endpoint at a loopback server is the repository's local stand-in for the vendor (a garage for
@@ -1013,8 +1248,16 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
1013
1248
  // app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
1014
1249
  // reason a live key committed to `.env.example` can never reach the emitted world.
1015
1250
  if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
1016
- env[name] = fakeEnvValue(name);
1017
- envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
1251
+ // AN APPLICATION'S OWN SECRET IS ISSUED AT A GENERATOR'S LENGTH. A name no vendor claims and no shape answers
1252
+ // (AUTH_SECRET, JWT_SECRET, SESSION_SECRET) is the application's own, and applications check theirs: kaneo
1253
+ // refuses one under 32 characters, and `twin-fake-auth-secret` is 21. It is issued as 64 hexadecimal
1254
+ // characters, what `openssl rand -hex 32` gives, made from the World's name and the variable's so a World made
1255
+ // twice is the same World and two Worlds do not share a secret anyone could compute from the variable alone.
1256
+ // A vendor's credential keeps `twin-fake-…`, which its twin accepts and which reads as what it is.
1257
+ const generic = fakeEnvValue(name);
1258
+ const appsOwn = generic === `twin-fake-${name.toLowerCase().replace(/_/g, '-')}` && envNameVendor(name) === null;
1259
+ env[name] = appsOwn ? createHash('sha256').update(`${worldName}\0${generic}`).digest('hex') : generic;
1260
+ envRows.push({ name, disposition: 'faked', source, reason: appsOwn ? 'the application\'s own secret — issued as 64 hex characters, never the example value' : 'credential-shaped name — the example value is never copied' });
1018
1261
  continue;
1019
1262
  }
1020
1263
  if (value !== '') {
@@ -1069,6 +1312,32 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
1069
1312
  row.unset = true;
1070
1313
  }
1071
1314
 
1315
+ // What the stack wires that no env file declared (an application with no env file at all), and what an env file
1316
+ // declared under a name no pattern knows, which was then kept or left as an ordinary value: the stack says it
1317
+ // names a service, so it takes the service's address.
1318
+ for (const [name, wired] of stackNames) {
1319
+ if (infraEntries.some((entry) => entry.name === name)) continue;
1320
+ const held = envRows.findIndex((row) => row.name === name);
1321
+ if (held >= 0) envRows.splice(held, 1);
1322
+ delete env[name];
1323
+ infraSignals.set(wired.kind, [...(infraSignals.get(wired.kind) ?? []), name]);
1324
+ infraEntries.push({ name, source: wired.source, kind: wired.kind, ...(wired.dsnFormat ? { dsnFormat: wired.dsnFormat, endpoint: wired.endpoint, dsnPrefix: wired.dsnPrefix, dsnProtocol: wired.dsnProtocol } : {}), ...(wired.scheme ? { scheme: wired.scheme } : {}), ...(wired.containerPort !== undefined ? { containerPort: wired.containerPort } : {}), ...(wired.query !== undefined ? { query: wired.query } : {}), ...(wired.host ? { host: true as const } : {}), ...(wired.withPort ? { withPort: true as const } : {}) });
1325
+ writeStackName(name);
1326
+ }
1327
+ // What was made of each service, from what was WRITTEN: a service is named, or declared under conventional
1328
+ // names, only by the names that were given its address.
1329
+ for (const { need, kind, fromScheme } of kinds) {
1330
+ const base = { service: need.service, ...(need.image !== undefined ? { image: need.image } : {}), ...(need.family !== undefined ? { family: need.family } : {}) };
1331
+ const written = [...(stackWritten.get(need.service) ?? [])].sort();
1332
+ const conventional = kind !== null && need.names.length === 0;
1333
+ stackNeeds.push({ ...base, kind, names: written, conventional,
1334
+ note: kind === null ? (need.family === undefined ? 'an image of no family the World knows: nothing is declared for it' : `the World manages no ${need.family}: nothing is declared for it${need.names.length ? `, and ${need.names.map((entry) => entry.name).join(', ')} keep what init gives them` : ''}`)
1335
+ : written.length === 0 ? (conventional ? (CONVENTIONAL_NAMES[kind] ? 'not told by the definition which variable names it, and its conventional names were given to another service or carry a value of another kind: nothing is written for it' : `not told by the definition which variable names it, and ${kind} has no conventional name: nothing is written for it`)
1336
+ : `its names (${need.names.map((entry) => entry.name).join(', ')}) were given to another service of the stack first: nothing is written for it`)
1337
+ : conventional ? 'not told by the definition which variable names it; the conventional names are supplied'
1338
+ : `the kind is told by ${fromScheme !== null ? 'a value\'s scheme' : 'the service\'s image'}` });
1339
+ }
1340
+
1072
1341
  // A selected twin's declared database is part of the World's infrastructure, even when the application
1073
1342
  // only reads that twin's issued connection URL. Do not substitute a guessed DATABASE_URL for its door.
1074
1343
  const infrastructurePath = resolve(out, 'world.infrastructure.yml');
@@ -1095,22 +1364,125 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
1095
1364
  .sort(([a], [b]) => a.localeCompare(b))
1096
1365
  .map(([kind, signals]) => composeService(name, kind, [...signals].sort(), takenPorts));
1097
1366
  const composeByKind = new Map(compose.map((service) => [service.kind, service]));
1367
+ // A declared provider image remains the source stack's choice through endpoint allocation.
1368
+ const requestedImages = new Map<string, string>();
1369
+ for (const need of stack?.needs ?? []) if (need.image) {
1370
+ const kind = need.family ? FAMILY_KIND[need.family] : undefined;
1371
+ const service = kind ? composeByKind.get(kind) : undefined;
1372
+ if (!service) continue;
1373
+ const prior = requestedImages.get(kind!);
1374
+ if (prior && prior !== need.image) throw new Error(`volter init: distinct requested ${kind} images cannot share one managed backing`);
1375
+ requestedImages.set(kind!, need.image); service.image = need.image;
1376
+ }
1377
+ for (const need of stack?.needs ?? []) if (need.originalInputs) {
1378
+ const kind = need.family ? FAMILY_KIND[need.family] : undefined;
1379
+ const service = kind ? composeByKind.get(kind) : undefined;
1380
+ if (!service) throw new Error(`volter init: original inputs for ${need.service} have no managed service`);
1381
+ if (service.originalInputs && service.originalInputs.sha256 !== need.originalInputs.sha256) throw new Error(`volter init: distinct original ${kind} service inputs cannot share one managed backing`);
1382
+ service.originalInputs = need.originalInputs;
1383
+ service.originalInputsFrom = dirname(resolve(options.needs!));
1384
+ }
1385
+
1386
+ for (const need of stack?.needs ?? []) if (need.nativeServerInputs) {
1387
+ const service = composeByKind.get('mysql');
1388
+ if (!service || need.family !== 'mysql') throw new Error('volter init: native MariaDB input has no managed MySQL service');
1389
+ if (service.originalInputs) throw new Error('volter init: a native provider cannot discard a separate original-input consumer requirement');
1390
+ if (service.nativeServerInputs && (service.nativeServerInputs.receiptSha256 !== need.nativeServerInputs.receiptSha256
1391
+ || service.nativeServerInputs.declarationSha256 !== need.nativeServerInputs.declarationSha256))
1392
+ throw new Error('volter init: distinct native MariaDB services cannot share one backing');
1393
+ service.nativeServerInputs = need.nativeServerInputs;
1394
+ service.nativeServerInputsFrom = dirname(resolve(options.needs!));
1395
+ service.nativeCredentialAliases = need.nativeCredentialAliases ?? [];
1396
+ service.image = need.nativeServerInputs.image;
1397
+ service.volume = 'native-mariadb-data';
1398
+ }
1399
+
1400
+ // The caller's MongoDB URI declares a server mode as well as connection options. Keep the raw query below,
1401
+ // and carry its set name to the backing instead of silently serving a standalone server at that address.
1402
+ const mongodb = composeByKind.get('mongodb');
1403
+ if (mongodb) {
1404
+ const sets = new Set<string>();
1405
+ for (const entry of infraEntries.filter(entry => entry.kind === 'mongodb' && entry.query !== undefined)) {
1406
+ const asked = new URLSearchParams(entry.query).getAll('replicaSet');
1407
+ if (asked.length > 1 || asked.some(value => !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value))) {
1408
+ throw new Error(`volter init: ${entry.name} must name one valid MongoDB replicaSet`);
1409
+ }
1410
+ for (const set of asked) sets.add(set);
1411
+ }
1412
+ if (sets.size > 1) throw new Error(`volter init: the managed MongoDB service cannot serve conflicting replicaSet names: ${[...sets].join(', ')}`);
1413
+ if (sets.size === 1) mongodb.replicaSet = [...sets][0]!;
1414
+ }
1415
+ const hostNamed = new Set(infraEntries.filter((entry) => entry.host).map((entry) => entry.name));
1416
+ // THE PORT BESIDE A HOST IS THE WORLD'S TO SAY. A stack that names a service by host and port apart (`REDIS_HOST:
1417
+ // redis`, `REDIS_PORT: 6379`) had its host pointed here and its port left at the stack's number, where nothing of
1418
+ // this World listens: the port is handed out per World, so two Worlds on one machine do not meet on 6379.
1419
+ for (const { need, kind } of kinds) {
1420
+ const composed = kind === null ? undefined : composeByKind.get(kind);
1421
+ if (composed === undefined) continue;
1422
+ for (const name of need.ports) {
1423
+ const held = envRows.findIndex((row) => row.name === name);
1424
+ if (held >= 0) envRows.splice(held, 1);
1425
+ const target = need.portTargets[name];
1426
+ if (composed.sides && target === undefined) throw new Error(`volter init: ${name} requires stack-needs version3 naming the vendor port for this multiwire service`);
1427
+ env[name] = String(managedPort(composed, target));
1428
+ envRows.push({ name, disposition: 'compose', source: `${stack!.composeFile} service ${need.service}`, reason: `${kind} port — the World-managed infrastructure service's, in place of the stack's own number` });
1429
+ }
1430
+ }
1098
1431
  for (const entry of infraEntries) {
1099
1432
  const composed = composeByKind.get(entry.kind);
1100
1433
  if (composed !== undefined) {
1101
- env[entry.name] = composed.url;
1434
+ // A variable the stack set to a bare host (`REDIS_HOST: redis`) is a host, not a connection string.
1435
+ // The World owns the authority and database. The caller's raw options
1436
+ // remain byte-for-byte, including an explicitly empty query.
1437
+ if (composed.sides && entry.host && entry.withPort && entry.containerPort === undefined) throw new Error(`volter init: ${entry.name} requires stack-needs version3 naming the vendor port`);
1438
+ const target = entry.containerPort ?? (entry.kind === 'clickhouse' && entry.scheme === 'clickhouse' ? 9000 : undefined);
1439
+ const physicalPort = managedPort(composed, target);
1440
+ const port = entry.dsnFormat && entry.endpoint === 'default-port' ? composed.containerPort : physicalPort;
1441
+ if (entry.dsnFormat && entry.endpoint === 'default-port') {
1442
+ if (target !== composed.containerPort) throw new Error(`volter init: ${entry.name} default port differs from its declared listener`);
1443
+ composed.virtualTcpEndpoint ??= { version: 1, host: `w${fnv1a(name).toString(16)}-${composed.kind}.world.volter.internal`, port, targetPort: physicalPort };
1444
+ }
1445
+ // URL.protocol cannot change a special HTTP URL into the non-special native scheme.
1446
+ const connection = new URL(entry.kind === 'clickhouse' && port !== composed.hostPort
1447
+ ? composed.url.replace(/^http:/, 'clickhouse:') : composed.url);
1448
+ if (entry.dsnFormat && entry.endpoint === 'default-port') connection.hostname = composed.virtualTcpEndpoint!.host;
1449
+ connection.port = String(port);
1450
+ if (entry.dsnFormat) {
1451
+ (composed.connectionBindings ??= {})[entry.name] = { dsnFormat: entry.dsnFormat, endpoint: entry.endpoint!,
1452
+ dsnPrefix: entry.dsnPrefix!, dsnProtocol: entry.dsnProtocol!, containerPort: entry.containerPort!, ...(entry.query !== undefined ? { query: entry.query } : {}) };
1453
+ }
1454
+ env[entry.name] = entry.dsnFormat ? renderGoMysqlBinding(entry as GoMysqlBinding & typeof entry, connection, port)
1455
+ : entry.host ? (entry.withPort ? `127.0.0.1:${port}` : '127.0.0.1')
1456
+ : `${entry.kind === 'clickhouse' ? connection.toString() : composed.url}${entry.query !== undefined ? `?${entry.query}` : ''}`;
1102
1457
  envRows.push({
1103
1458
  name: entry.name,
1104
1459
  disposition: 'compose',
1105
1460
  source: entry.source,
1106
- reason: `${entry.kind} connection string — points at the World-managed infrastructure service`,
1461
+ reason: entry.dsnFormat ? 'mysql connection in its declared Go driver grammar — from the World-managed provider' : entry.host
1462
+ ? (entry.withPort ? `${entry.kind} host and port — the World-managed infrastructure service's` : `${entry.kind} host — the World-managed infrastructure service's (its port is ${composed.hostPort}; a port variable beside it is not set by this)`)
1463
+ : `${entry.kind} connection string — points at the World-managed infrastructure service`,
1107
1464
  });
1108
1465
  } else {
1466
+ if (entry.dsnFormat) throw new Error(`volter init: ${entry.name} needs a supplied MySQL service for its declared Go driver grammar`);
1109
1467
  env[entry.name] = INFRA_PLACEHOLDER[entry.kind] ?? 'REPLACE_ME';
1110
1468
  envRows.push({ name: entry.name, disposition: 'placeholder', source: entry.source, reason: `${entry.kind} connection string — no twin models it; point it at your own local instance` });
1111
1469
  }
1112
1470
  }
1113
1471
 
1472
+ // A split field comes from the same connection as the host/URL. It is not
1473
+ // an endpoint signal: discovery must never replace a scalar with a URL.
1474
+ for (const [name, binding] of stackComponents) {
1475
+ const composed = composeByKind.get(binding.kind);
1476
+ if (!composed) throw new Error(`volter init: ${name} needs a managed ${binding.kind} connection for its ${binding.component}`);
1477
+ const url = new URL(composed.url);
1478
+ const held = envRows.findIndex(row => row.name === name);
1479
+ if (held >= 0) envRows.splice(held, 1);
1480
+ const value = decodeURIComponent(binding.component === 'database' ? url.pathname.slice(1) : binding.component === 'user' ? url.username : url.password);
1481
+ if (value === '') throw new Error(`volter init: ${name} asks for a ${binding.component} the managed ${binding.kind} connection does not provide`);
1482
+ env[name] = value;
1483
+ envRows.push({ name, disposition: 'compose', source: binding.source, reason: `${binding.kind} ${binding.component} — from the World-managed connection, beside its host and port` });
1484
+ }
1485
+
1114
1486
  const infra: InitInfraStub[] = [...infraSignals.entries()]
1115
1487
  .filter(([kind]) => !composeByKind.has(kind))
1116
1488
  .sort(([a], [b]) => a.localeCompare(b))
@@ -1151,7 +1523,10 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
1151
1523
  external: {
1152
1524
  up: ['volter-world-infra', 'up'],
1153
1525
  status: ['volter-world-infra', 'status'],
1154
- discover: compose.flatMap((service) => service.signals.map((name) => ({ as: name, jsonPath: `connections.${name}`, ...(['postgres', 'mysql', 'mongodb', 'redis'].includes(service.kind) ? { protocol: service.kind } : {}) }))),
1526
+ // Only a connection string is discovered: status publishes the values that are a URL at the service's own
1527
+ // port (infraConnections), so a variable that holds a bare host (`REDIS_HOST: redis`, written above as
1528
+ // 127.0.0.1) is never among them, and asking for it made `up` fail on a World whose services were ready.
1529
+ discover: compose.flatMap((service) => service.signals.filter((name) => !hostNamed.has(name)).map((name) => ({ as: name, jsonPath: `connections.${name}`, ...(['postgres', 'mysql', 'mongodb', 'redis', 'clickhouse'].includes(service.kind) ? { protocol: service.kind === 'clickhouse' ? (env[name]?.startsWith('clickhouse:') ? 'clickhouse' : 'http') : service.kind } : {}) }))),
1155
1530
  down: ['volter-world-infra', 'down'],
1156
1531
  readyWhen: { command: 'volter-world-infra', args: ['status'], timeoutMs: 120000, intervalMs: 500 },
1157
1532
  },
@@ -1250,6 +1625,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
1250
1625
  ? [] : projectMigrationTools(repo),
1251
1626
  infra,
1252
1627
  compose,
1628
+ ...(stack ? { stackNeeds, stackEnvironmentNames, leftAsThePersonsOwn: filesLeftAsThePersonsOwn() } : {}),
1253
1629
  composePath: compose.length > 0 ? resolve(out, 'world.infrastructure.yml') : null,
1254
1630
  // Standing registry acknowledgments leave the worklist but never vanish (each keeps its
1255
1631
  // recorded reason) — the same partition coverWorld's proof applies to the same signals.
@@ -1330,7 +1706,17 @@ export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}
1330
1706
  }
1331
1707
  // Recheck the planned name-derived paths before any output is written.
1332
1708
  for (const copy of plan.defaultsCopies) ownedPath(plan.out, relative(plan.out, copy.to));
1709
+ for (const service of plan.compose) if (service.nativeServerInputs)
1710
+ ownedPath(plan.out, `native-inputs/${service.nativeServerInputs.receiptSha256}/${service.nativeServerInputs.declarationSha256}`);
1333
1711
  mkdirSync(plan.out, { recursive: true });
1712
+ for (const service of plan.compose) if (service.nativeServerInputs) {
1713
+ if (!service.nativeServerInputsFrom) throw new Error('Native managed input has no retained source parent');
1714
+ copyNativeServerInputs(service.nativeServerInputsFrom, service.nativeServerInputs, plan.out);
1715
+ }
1716
+ for (const service of plan.compose) if (service.originalInputs) {
1717
+ if (!service.originalInputsFrom) throw new Error('Original inputs have no retained source parent');
1718
+ copyOriginalInputSnapshot(service.originalInputsFrom, service.originalInputs, plan.out);
1719
+ }
1334
1720
  writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
1335
1721
  if (plan.inRepo) writeFileSync(join(plan.out, '.gitignore'), STATE_GITIGNORE);
1336
1722
  writeFileSync(plan.envPath, renderEnvFile(plan));
@@ -1448,6 +1834,7 @@ export function initWorld(name: string, repoPath: string, options: InitOptions =
1448
1834
  root: resolve(options.root ?? process.cwd()),
1449
1835
  allowUnknown: options.allowUnknown ?? false,
1450
1836
  acknowledge: options.acknowledge ?? {},
1837
+ ...(plan.stackEnvironmentNames ? { declaredEnvironmentNames: plan.stackEnvironmentNames } : {}),
1451
1838
  });
1452
1839
  // The proof was addressed BY PATH (so it reads the bytes just written, not the in-memory plan);
1453
1840
  // report it under the world's id, which is what every other verb takes.
@@ -1538,6 +1925,18 @@ export function formatInitReport(result: InitResult): string {
1538
1925
  }
1539
1926
  lines.push(' Resolve these before expecting the app to listen. Vendor coverage can be green while the app rejects its own env.');
1540
1927
  }
1928
+ if (plan.stackNeeds !== undefined) {
1929
+ const told = (test: (need: InitStackNeed) => boolean): number => plan.stackNeeds!.filter(test).length;
1930
+ lines.push('');
1931
+ lines.push('READ FOR AN IMAGE: only files the project\'s source holds were read (--needs).');
1932
+ for (const file of plan.leftAsThePersonsOwn ?? []) lines.push(` ${file} is the person's own and was not read`);
1933
+ lines.push(`THE PROJECT'S OWN STACK (${plan.stackNeeds.length} service${plan.stackNeeds.length === 1 ? '' : 's'} beside the application): `
1934
+ + `${told((need) => need.kind !== null && !need.conventional && need.names.length > 0)} given their address under the stack's own variables, ${told((need) => need.conventional && need.names.length > 0)} under conventional names, `
1935
+ + `${told((need) => need.kind !== null && need.names.length === 0)} of a managed kind with nothing written, ${told((need) => need.kind === null)} the World has no kind for`);
1936
+ for (const need of plan.stackNeeds) {
1937
+ lines.push(` ${need.service}${need.image ? ` (${need.image})` : ''}: ${need.kind ?? 'no managed kind'}${need.names.length ? ` — ${need.names.join(', ')}` : ''}; ${need.note}`);
1938
+ }
1939
+ }
1541
1940
  if (plan.undeclaredMigrations.length > 0) {
1542
1941
  lines.push('');
1543
1942
  lines.push(`MIGRATION TOOL FOUND, NO STEP DECLARED (${plan.undeclaredMigrations.length}):`);