@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.
- package/dist/src/ancestry.js +2 -2
- package/dist/src/app-url.d.ts +5 -1
- package/dist/src/app-url.js +14 -1
- package/dist/src/branch.js +28 -8
- package/dist/src/browser-files.d.ts +5 -0
- package/dist/src/browser-files.js +14 -0
- package/dist/src/browsers.d.ts +2 -3
- package/dist/src/browsers.js +4 -4
- package/dist/src/catalog.d.ts +2 -1
- package/dist/src/catalog.js +3 -2
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli-commands.js +71 -18
- package/dist/src/cli-load-clock.d.ts +1 -0
- package/dist/src/cli-load-clock.js +2 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/clickhouse-backing.d.ts +2 -0
- package/dist/src/clickhouse-backing.js +145 -0
- package/dist/src/command-lifetime.d.mts +1 -1
- package/dist/src/command-lifetime.mjs +5 -2
- package/dist/src/configs.js +1 -1
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +4 -2
- package/dist/src/covers.js +8 -4
- package/dist/src/go-mysql-binding.d.ts +19 -0
- package/dist/src/go-mysql-binding.js +51 -0
- package/dist/src/guest-processes.d.mts +1 -1
- package/dist/src/guest-processes.mjs +22 -14
- package/dist/src/host-cli.js +8 -1
- package/dist/src/host-ready.d.ts +16 -2
- package/dist/src/host-ready.js +66 -39
- package/dist/src/host-worker.js +7 -1
- package/dist/src/host.d.ts +26 -0
- package/dist/src/host.js +93 -3
- package/dist/src/infra-definition.d.ts +35 -0
- package/dist/src/infra-definition.js +146 -0
- package/dist/src/infra.d.ts +3 -0
- package/dist/src/infra.js +375 -105
- package/dist/src/init.d.ts +46 -0
- package/dist/src/init.js +445 -18
- package/dist/src/lifecycle-record.d.ts +5 -1
- package/dist/src/lifecycle-record.js +27 -4
- package/dist/src/local-branches.js +1 -1
- package/dist/src/mariadb-bootstrap.d.ts +32 -0
- package/dist/src/mariadb-bootstrap.js +90 -0
- package/dist/src/mariadb-supplier.d.ts +23 -0
- package/dist/src/mariadb-supplier.js +215 -0
- package/dist/src/mongodb-backing.d.ts +4 -0
- package/dist/src/mongodb-backing.js +71 -1
- package/dist/src/native-mariadb-backing.d.ts +13 -0
- package/dist/src/native-mariadb-backing.js +512 -0
- package/dist/src/native-postgres-backing.d.ts +4 -10
- package/dist/src/native-postgres-backing.js +428 -43
- package/dist/src/native-server-inputs.d.ts +16 -0
- package/dist/src/native-server-inputs.js +204 -0
- package/dist/src/origin.js +3 -3
- package/dist/src/original-inputs.d.ts +14 -0
- package/dist/src/original-inputs.js +84 -0
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pglite-backing.d.ts +41 -17
- package/dist/src/pglite-backing.js +213 -79
- package/dist/src/pglite-host.mjs +482 -10
- package/dist/src/process-groups.d.mts +6 -1
- package/dist/src/process-groups.d.ts +2 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/process-groups.mjs +82 -15
- package/dist/src/process-signal.d.mts +2 -2
- package/dist/src/process-signal.mjs +29 -9
- package/dist/src/project-inspect.d.ts +12 -0
- package/dist/src/project-inspect.js +41 -4
- package/dist/src/proxy-env.d.ts +34 -0
- package/dist/src/proxy-env.js +114 -0
- package/dist/src/redirect-proxy.d.ts +1 -31
- package/dist/src/redirect-proxy.js +3 -112
- package/dist/src/redis-backing.d.ts +6 -4
- package/dist/src/redis-backing.js +20 -168
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +503 -218
- package/dist/src/schema.d.ts +1 -0
- package/dist/src/schema.js +5 -3
- package/dist/src/served-world.js +15 -10
- package/dist/src/service-exit.d.ts +3 -2
- package/dist/src/service-exit.js +7 -5
- package/dist/src/twin-backing.d.ts +47 -0
- package/dist/src/twin-backing.js +343 -0
- package/dist/src/up-cli.js +56 -3
- package/package.json +4 -4
- package/src/ancestry.ts +2 -2
- package/src/app-url.ts +9 -2
- package/src/branch.ts +26 -8
- package/src/browser-files.ts +15 -0
- package/src/browsers.ts +4 -4
- package/src/catalog.ts +4 -3
- package/src/changeset.ts +1 -1
- package/src/cli-commands.ts +85 -18
- package/src/cli-load-clock.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/clickhouse-backing.ts +70 -0
- package/src/command-lifetime.d.mts +1 -1
- package/src/command-lifetime.mjs +5 -2
- package/src/configs.ts +1 -1
- package/src/consumers.ts +1 -1
- package/src/covers.ts +9 -4
- package/src/go-mysql-binding.ts +49 -0
- package/src/guest-processes.d.mts +1 -1
- package/src/guest-processes.mjs +22 -14
- package/src/host-cli.ts +7 -1
- package/src/host-ready.ts +62 -31
- package/src/host-worker.ts +6 -2
- package/src/host.ts +88 -6
- package/src/infra-definition.ts +135 -0
- package/src/infra.ts +250 -45
- package/src/init.ts +416 -17
- package/src/lifecycle-record.ts +18 -4
- package/src/local-branches.ts +1 -1
- package/src/mariadb-bootstrap.ts +107 -0
- package/src/mariadb-supplier.ts +199 -0
- package/src/mongodb-backing.ts +59 -1
- package/src/native-mariadb-backing.ts +418 -0
- package/src/native-postgres-backing.ts +353 -41
- package/src/native-server-inputs.ts +169 -0
- package/src/origin.ts +3 -3
- package/src/original-inputs.ts +72 -0
- package/src/pack-facts.ts +1 -1
- package/src/pglite-backing.ts +192 -80
- package/src/pglite-host.mjs +482 -10
- package/src/process-groups.d.mts +6 -1
- package/src/process-groups.mjs +82 -15
- package/src/process-groups.ts +2 -1
- package/src/process-signal.d.mts +2 -2
- package/src/process-signal.mjs +29 -9
- package/src/project-inspect.ts +37 -4
- package/src/proxy-env.ts +128 -0
- package/src/redirect-proxy.ts +3 -125
- package/src/redis-backing.ts +19 -136
- package/src/runtime.ts +342 -107
- package/src/schema.ts +5 -4
- package/src/served-world.ts +14 -10
- package/src/service-exit.ts +5 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Source-declared Go MySQL connection bindings; architecture, managed infrastructure. */
|
|
2
|
+
export type GoMysqlBinding = { dsnFormat: 'go-mysql'; endpoint: 'explicit-port' | 'default-port'; dsnPrefix: '' | 'mysql://'; dsnProtocol: '' | 'tcp' };
|
|
3
|
+
|
|
4
|
+
/** Only versioned, explicitly typed data selects a driver grammar. */
|
|
5
|
+
export function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding> {
|
|
6
|
+
if (entry.dsnFormat === undefined) {
|
|
7
|
+
if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined) throw new Error(`volter init: ${name} has DSN fields without a declared format`);
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
if ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
|
|
11
|
+
|| (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
|
|
12
|
+
|| (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
|
|
13
|
+
|| (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
|
|
14
|
+
|| (entry.dsnPrefix === 'mysql://' && entry.endpoint === 'explicit-port')
|
|
15
|
+
|| (entry.query !== undefined && (typeof entry.query !== 'string' || /[\u0000-\u001f\u007f]/u.test(entry.query)))) {
|
|
16
|
+
throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
|
|
17
|
+
}
|
|
18
|
+
return { dsnFormat: 'go-mysql', endpoint: entry.endpoint as GoMysqlBinding['endpoint'],
|
|
19
|
+
dsnPrefix: entry.dsnPrefix as GoMysqlBinding['dsnPrefix'], dsnProtocol: entry.dsnProtocol as GoMysqlBinding['dsnProtocol'] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderGoMysqlBinding(entry: GoMysqlBinding & { name: string; containerPort?: number; query?: string }, connection: URL, port: number): string {
|
|
23
|
+
if (connection.protocol !== 'mysql:' || entry.containerPort === undefined) throw new Error(`volter init: ${entry.name} needs a supplied MySQL endpoint and declared vendor port`);
|
|
24
|
+
if (entry.endpoint === 'default-port' && port !== entry.containerPort) throw new Error(`volter init: ${entry.name} needs a per-service host serving its declared default port; the shared-host high-port allocation cannot supply it`);
|
|
25
|
+
const user = decodeURIComponent(connection.username), password = decodeURIComponent(connection.password), database = decodeURIComponent(connection.pathname.slice(1));
|
|
26
|
+
if (!user || !password || !database || /[:\u0000-\u001f\u007f]/u.test(user) || /[\u0000-\u001f\u007f]/u.test(password)) throw new Error(`volter init: ${entry.name} cannot represent the supplied MySQL credentials in its declared driver grammar`);
|
|
27
|
+
if (entry.dsnPrefix === 'mysql://' && (entry.endpoint === 'explicit-port'
|
|
28
|
+
|| !/^[A-Za-z0-9\-._~!$&'()*+,;=:%@]*$/u.test(`${user}:${password}`) || /%(?![A-Fa-f0-9]{2})/u.test(`${user}:${password}`))) {
|
|
29
|
+
throw new Error(`volter init: ${entry.name} cannot preserve its raw credentials/port through the declared mysql:// URL preparse`);
|
|
30
|
+
}
|
|
31
|
+
const address = `${connection.hostname}${entry.endpoint === 'explicit-port' ? `:${port}` : ''}`;
|
|
32
|
+
return `${entry.dsnPrefix}${user}:${password}@${entry.dsnProtocol}(${address})/${encodeURIComponent(database)}${entry.query !== undefined ? `?${entry.query}` : ''}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Match a declared DSN against its owned listener, never infer a grammar. */
|
|
37
|
+
export function goMysqlEndpoint(value: string, binding: GoMysqlBinding, declaredPort: number): { host: string; port: number } | undefined {
|
|
38
|
+
if (/[\u0000-\u001f\u007f]/u.test(value) || !value.startsWith(binding.dsnPrefix)) return undefined;
|
|
39
|
+
const body = value.slice(binding.dsnPrefix.length), slash = body.lastIndexOf('/');
|
|
40
|
+
if (slash < 0) return undefined;
|
|
41
|
+
const authority = body.slice(0, slash), at = authority.lastIndexOf('@');
|
|
42
|
+
const transport = authority.slice(at + 1), begin = `${binding.dsnProtocol}(`;
|
|
43
|
+
if (!transport.startsWith(begin) || !transport.endsWith(')')) return undefined;
|
|
44
|
+
const endpoint = /^(?:\[([^\]\s]+)\]|([^:\s/?#()]+))(?::([0-9]{1,5}))?$/u.exec(transport.slice(begin.length, -1));
|
|
45
|
+
if (!endpoint || (binding.endpoint === 'default-port' && endpoint[3] !== undefined)
|
|
46
|
+
|| (binding.endpoint === 'explicit-port' && endpoint[3] === undefined)) return undefined;
|
|
47
|
+
const port = endpoint[3] === undefined ? declaredPort : Number(endpoint[3]);
|
|
48
|
+
return port >= 1 && port <= 65535 ? { host: endpoint[1] ?? endpoint[2]!, port } : undefined;
|
|
49
|
+
}
|
|
@@ -5,5 +5,5 @@ export interface GuestProcessLaunch { path: string; record: Omit<GuestProcessRec
|
|
|
5
5
|
export function guestProcessDirectory(instanceDir: string): string;
|
|
6
6
|
export function beginGuestProcess(directory: string, instance: string, guest: string, kind: GuestProcessRecord['kind']): GuestProcessLaunch;
|
|
7
7
|
export function retainGuestProcess(receipt: GuestProcessLaunch, identity: SpawnIdentity): void;
|
|
8
|
-
export function readGuestProcesses(instanceDir: string, expectedInstance?: string): GuestProcessReceipt[];
|
|
8
|
+
export function readGuestProcesses(instanceDir: string, expectedInstance?: string, onError?: (error: unknown) => void): GuestProcessReceipt[];
|
|
9
9
|
export function releaseGuestProcesses(receipts: Array<{ path: string }>): void;
|
package/src/guest-processes.mjs
CHANGED
|
@@ -16,7 +16,9 @@ export function retainGuestProcess(receipt, identity) {
|
|
|
16
16
|
receipt.record = { ...receipt.record, launching: false, ...identity };
|
|
17
17
|
publishProcessReceipt(receipt.path, JSON.stringify(receipt.record));
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// Teardown can retain each refusal while joining the other independently valid receipts.
|
|
20
|
+
// Other readers remain strict unless they explicitly collect these errors.
|
|
21
|
+
export function readGuestProcesses(instanceDir, expectedInstance, onError) {
|
|
20
22
|
const directory = guestProcessDirectory(instanceDir);
|
|
21
23
|
if (!existsSync(directory)) return [];
|
|
22
24
|
if (lstatSync(directory).isSymbolicLink()) throw new Error('Guest receipt directory is a symbolic link; ownership retained');
|
|
@@ -26,19 +28,25 @@ export function readGuestProcesses(instanceDir, expectedInstance) {
|
|
|
26
28
|
try { expectedInstance = JSON.parse(readFileSync(join(instanceDir, 'instance.json'), 'utf8')).instance; }
|
|
27
29
|
catch { throw new Error('Guest instance identity unreadable; ownership retained'); }
|
|
28
30
|
}
|
|
29
|
-
return names.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
return names.flatMap(name => {
|
|
32
|
+
try {
|
|
33
|
+
const path = join(directory, name);
|
|
34
|
+
if (!/^[0-9a-f-]+\.json$/.test(name) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unresolved guest receipt publication; ownership retained');
|
|
35
|
+
const receipt = readProcessReceipt(path, text => {
|
|
36
|
+
const r = JSON.parse(text);
|
|
37
|
+
if (r.instance !== expectedInstance || typeof r.guest !== 'string' || !['agent', 'command', 'pty'].includes(r.kind)
|
|
38
|
+
|| r.launching || !Number.isInteger(r.pid) || r.pid < 1 || (r.start !== undefined && (typeof r.start !== 'string' || !r.start))) {
|
|
39
|
+
throw new Error('Guest launch or spawn identity unresolved');
|
|
40
|
+
}
|
|
41
|
+
return r;
|
|
42
|
+
});
|
|
43
|
+
if (receipt.error || !receipt.value) throw receipt.error ?? new Error('Guest receipt disappeared during inspection; ownership retained');
|
|
44
|
+
return [{ path, record: receipt.value }];
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!onError) throw error;
|
|
47
|
+
onError(error);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
42
50
|
});
|
|
43
51
|
}
|
|
44
52
|
// The caller must have confirmed each recorded group absent before releasing these receipts.
|
package/src/host-cli.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { dirname, join } from 'node:path';
|
|
|
15
15
|
import { startColocatedHost, type ColocatedTwinSpec, type HostIsolation } from './host.ts';
|
|
16
16
|
import { publishHostReady } from './host-ready.ts';
|
|
17
17
|
|
|
18
|
+
// When this entry runs: the host's own modules are loaded; each twin's are loaded by its factory (S2 R38).
|
|
19
|
+
const began = Date.now();
|
|
20
|
+
const startedAt: Record<string, number> = {};
|
|
18
21
|
const argv = process.argv.slice(2);
|
|
19
22
|
const opt = (name: string, fb = ''): string => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1]! : fb; };
|
|
20
23
|
|
|
@@ -82,6 +85,7 @@ function recordGaveUp(twin: string, exits: number, detail: string): void {
|
|
|
82
85
|
const host = await startColocatedHost(specs, {
|
|
83
86
|
isolation,
|
|
84
87
|
onEvent: (e) => {
|
|
88
|
+
if (e.kind === 'started') startedAt[e.twin] = Date.now();
|
|
85
89
|
process.stdout.write(`event twin=${e.twin} kind=${e.kind}${e.detail ? ` detail=${e.detail}` : ''}\n`);
|
|
86
90
|
if (e.kind === 'gaveup') recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
|
|
87
91
|
},
|
|
@@ -91,9 +95,11 @@ process.on('SIGTERM', shutdown);
|
|
|
91
95
|
process.on('SIGINT', shutdown);
|
|
92
96
|
const readyFile = opt('--ready-file');
|
|
93
97
|
if (readyFile) {
|
|
94
|
-
try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports); }
|
|
98
|
+
try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports, { began, started: startedAt }, host.streams); }
|
|
95
99
|
catch (error) { await host.stop(); throw error; }
|
|
96
100
|
}
|
|
101
|
+
// the twins' cold first requests, taken in the host's idle time after its receipt rather than by the application's first calls
|
|
102
|
+
void host.warm();
|
|
97
103
|
for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
98
104
|
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
99
105
|
await keepProcessAlive();
|
package/src/host-ready.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { type FSWatcher, mkdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
|
+
/** When the host's own start reached each step, in epoch milliseconds: its entry running (its static modules loaded),
|
|
5
|
+
* and each twin's factory returning. */
|
|
6
|
+
export type HostStartTimes = { began: number; started: Record<string, number> };
|
|
7
|
+
|
|
8
|
+
/** What a receipt says of the host's start, for the runtime's progress line, and which of its twins' main ports are byte
|
|
9
|
+
* streams (host.ts servesByteStream), for their admission; absent from a host that predates it. */
|
|
10
|
+
export type HostReadyTimes = { at?: number; streams?: string[] } & Partial<HostStartTimes>;
|
|
11
|
+
|
|
4
12
|
/** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
|
|
5
|
-
export function publishHostReady(path: string, boot: string, ports: Record<string, number
|
|
13
|
+
export function publishHostReady(path: string, boot: string, ports: Record<string, number>, times?: HostStartTimes, streams?: readonly string[]): void {
|
|
6
14
|
if (!boot) throw new Error('A co-located host readiness receipt requires its World boot identity.');
|
|
7
15
|
mkdirSync(dirname(path), { recursive: true });
|
|
8
16
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
9
|
-
|
|
17
|
+
// `at`, when it was published, lets the runtime say how long its read came after, and `times` where the host's own
|
|
18
|
+
// start went before it (S2 R28, R38).
|
|
19
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports, at: Date.now(), ...times, ...(streams?.length ? { streams } : {}) })}\n`, { mode: 0o600 });
|
|
10
20
|
renameSync(temporary, path);
|
|
11
21
|
}
|
|
12
22
|
|
|
@@ -16,35 +26,56 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
|
|
|
16
26
|
* one: each change in `progress` (the host log's size, which grows with every
|
|
17
27
|
* twin it starts) begins the bound again, so a host still starting its twins
|
|
18
28
|
* on a busy machine is waited for, and one silent for `timeoutMs` is not.
|
|
29
|
+
* Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
|
|
30
|
+
* twins' main ports are byte streams.
|
|
19
31
|
*/
|
|
20
|
-
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<HostReadyTimes> {
|
|
33
|
+
// A change in the receipt's directory (the host's rename is one) wakes the wait at once, where the 25 ms timer
|
|
34
|
+
// alone left a published receipt unread for up to a tick (S2 R28). The timer stays: a filesystem that gives no
|
|
35
|
+
// change events is read as before. Any other write in the directory wakes it too, and costs one read.
|
|
36
|
+
let wake: (() => void) | undefined;
|
|
37
|
+
let watcher: FSWatcher | undefined;
|
|
38
|
+
try {
|
|
39
|
+
watcher = watch(dirname(path), { persistent: false }, () => wake?.());
|
|
40
|
+
watcher.on('error', () => { watcher?.close(); watcher = undefined; });
|
|
41
|
+
} catch { /* no change events here: the timer alone */ }
|
|
42
|
+
try {
|
|
43
|
+
let started = Date.now();
|
|
44
|
+
let seen = progress();
|
|
45
|
+
for (;;) {
|
|
46
|
+
abort.throwIfAborted();
|
|
47
|
+
let bytes: Buffer | undefined;
|
|
48
|
+
try { bytes = readFileSync(path); }
|
|
49
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
50
|
+
if (bytes) {
|
|
51
|
+
if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
|
|
52
|
+
const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown; at?: unknown; began?: unknown; started?: unknown; streams?: unknown } | null;
|
|
53
|
+
if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
|
|
54
|
+
throw new Error('The co-located host readiness receipt does not belong to this boot.');
|
|
55
|
+
}
|
|
56
|
+
const answered = value.ports as Record<string, unknown>;
|
|
57
|
+
if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
|
|
58
|
+
throw new Error('The co-located host readiness receipt does not cover its declared ports.');
|
|
59
|
+
}
|
|
60
|
+
const when = (time: unknown): number | undefined => typeof time === 'number' && Number.isFinite(time) ? time : undefined;
|
|
61
|
+
// only the declared twins' times: an id the runtime did not declare never reaches its progress line
|
|
62
|
+
const twinStarts = value.started && typeof value.started === 'object' && !Array.isArray(value.started)
|
|
63
|
+
? Object.fromEntries(Object.entries(value.started as Record<string, unknown>).flatMap(([id, time]) => !ports.has(id) || when(time) === undefined ? [] : [[id, time as number]]))
|
|
64
|
+
: undefined;
|
|
65
|
+
const streams = Array.isArray(value.streams) ? value.streams.filter((id): id is string => typeof id === 'string' && ports.has(id)) : [];
|
|
66
|
+
return { at: when(value.at), began: when(value.began), ...(twinStarts ? { started: twinStarts } : {}), ...(streams.length ? { streams } : {}) };
|
|
37
67
|
}
|
|
38
|
-
|
|
68
|
+
const now = progress();
|
|
69
|
+
if (now !== seen) { seen = now; started = Date.now(); }
|
|
70
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
const settle = (): void => { clearTimeout(timer); wake = undefined; abort.removeEventListener('abort', cancel); };
|
|
73
|
+
const cancel = (): void => { settle(); reject(abort.reason); };
|
|
74
|
+
const timer = setTimeout(() => { settle(); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
75
|
+
wake = () => { settle(); resolve(); };
|
|
76
|
+
abort.addEventListener('abort', cancel, { once: true });
|
|
77
|
+
if (abort.aborted) cancel();
|
|
78
|
+
});
|
|
39
79
|
}
|
|
40
|
-
|
|
41
|
-
if (now !== seen) { seen = now; started = Date.now(); }
|
|
42
|
-
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
|
|
43
|
-
await new Promise<void>((resolve, reject) => {
|
|
44
|
-
const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
|
|
45
|
-
const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
46
|
-
abort.addEventListener('abort', cancel, { once: true });
|
|
47
|
-
if (abort.aborted) cancel();
|
|
48
|
-
});
|
|
49
|
-
}
|
|
80
|
+
} finally { watcher?.close(); }
|
|
50
81
|
}
|
package/src/host-worker.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime
|
|
|
4
4
|
// host does NOT restart it: it gives up on the worker (fires a `gaveup` event → doctor
|
|
5
5
|
// red) without disturbing sibling twins. Posts {type:'ready'} once listening.
|
|
6
6
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
7
|
-
import type
|
|
7
|
+
import { servesByteStream, servesDiscovery, warmInProcess, type ColocatedTwinSpec } from './host.ts';
|
|
8
8
|
import { importModule } from './import-module.ts';
|
|
9
9
|
|
|
10
10
|
const spec = workerData as ColocatedTwinSpec;
|
|
@@ -23,6 +23,10 @@ if (typeof factory !== 'function') {
|
|
|
23
23
|
|
|
24
24
|
// Awaited: an async factory (fly) must post `ready` only once its listener is bound.
|
|
25
25
|
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
26
|
-
parentPort?.postMessage({ type: 'ready', port: server.port });
|
|
26
|
+
parentPort?.postMessage({ type: 'ready', port: server.port, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) });
|
|
27
|
+
// the host's warm-up, taken here where this twin's server and its handler are (host.ts warmInProcess)
|
|
28
|
+
parentPort?.on('message', (msg: { type?: string }) => {
|
|
29
|
+
if (msg?.type === 'warm') void warmInProcess(server.port).then(() => parentPort?.postMessage({ type: 'warmed' }));
|
|
30
|
+
});
|
|
27
31
|
|
|
28
32
|
// The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
|
package/src/host.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorMessage } from './error-message.ts';
|
|
2
|
-
import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
|
|
2
|
+
import { getPack, registerPack, servedHandlerOf, type TwinPack } from '@volter/world-core/runtime';
|
|
3
3
|
// Co-located twin host — run many twins in ONE process instead of one OS process
|
|
4
4
|
// per twin. The twin core is a pure, transport-agnostic handler (see a pack's
|
|
5
5
|
// `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
|
|
@@ -53,6 +53,12 @@ export type ColocatedHost = {
|
|
|
53
53
|
isolation: HostIsolation;
|
|
54
54
|
/** Resolved listening port per twin id (equals the requested port). */
|
|
55
55
|
ports: Record<string, number>;
|
|
56
|
+
/** The twins whose main port is a byte stream (servesByteStream). */
|
|
57
|
+
streams: string[];
|
|
58
|
+
/** The twins whose main port answers the discovery door (GET /twin: the manifest declares `discovery`), warmed by it. */
|
|
59
|
+
discoverable: string[];
|
|
60
|
+
/** Warm every discoverable twin (warmInProcess, in its own thread under worker isolation); never needed for readiness. */
|
|
61
|
+
warm: () => Promise<void>;
|
|
56
62
|
stop: () => Promise<void>;
|
|
57
63
|
};
|
|
58
64
|
|
|
@@ -65,7 +71,58 @@ export type ColocatedHost = {
|
|
|
65
71
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
66
72
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
/** Whether a twin's main port is a byte stream: a raw-tcp pack exporting exactly one `create<Name>TwinStream` is taken
|
|
75
|
+
* to serve that stream there (world-core serveStream: an SMTP relay greets, a Redis server answers RESP). Such a port
|
|
76
|
+
* cannot answer an HTTP request, so the runtime admits it by its TCP answer (runtime.ts, startColocatedServices). Read
|
|
77
|
+
* off the module's own exports, as its serve factory is (twin-standard pack-facts.ts serveExport). A pack whose main
|
|
78
|
+
* port serves HTTP or gRPC must not declare raw-tcp with a stream export (create-pack refuses it). */
|
|
79
|
+
export function servesByteStream(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
|
|
80
|
+
return descriptor.transport === 'raw-tcp'
|
|
81
|
+
&& Object.keys(mod).filter((key) => /^create\w*TwinStream$/.test(key) && typeof mod[key] === 'function').length === 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a twin's main port answers the discovery door: its manifest declares `discovery`, and its port is HTTP. */
|
|
85
|
+
export function servesDiscovery(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
|
|
86
|
+
const manifest = mod.manifest as { discovery?: unknown } | undefined;
|
|
87
|
+
return manifest?.discovery !== undefined && !servesByteStream(mod, descriptor);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The most twins warmed at once (S2 R41). */
|
|
91
|
+
const WARM_CONCURRENCY = 2;
|
|
92
|
+
/** A warm-up's bound: one that has not finished by then is left, its twin as it is. */
|
|
93
|
+
const WARM_TIMEOUT_MS = 2000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Each twin's first request pays its pipeline's cold start (decoding, routing, the pack's front, first-run code), which
|
|
97
|
+
* otherwise falls on the application's first call to it. Its size for an HTTP twin is unmeasured (a byte-stream twin's
|
|
98
|
+
* first SMTP greeting took 57 ms against 1.8 ms later, e946aa4c run 3 on the mini, but that twin is skipped here); the
|
|
99
|
+
* R38 timing lap sizes it. The host takes it in its own idle time once its
|
|
100
|
+
* receipt is published: one GET of the twin's discovery door, handed in this process to the handler serving its port
|
|
101
|
+
* (world-core servedHandlerOf), never on the wire and never in its request journal. Bounded, its answer and any failure
|
|
102
|
+
* ignored; a request the application sends first is served as it would have been, and simply is that twin's warm-up.
|
|
103
|
+
* The discovery door reads no stored state, so a twin's first state read stays cold.
|
|
104
|
+
*/
|
|
105
|
+
export async function warmInProcess(port: number): Promise<void> {
|
|
106
|
+
const handler = servedHandlerOf(port);
|
|
107
|
+
if (!handler) return;
|
|
108
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
try {
|
|
110
|
+
await Promise.race([
|
|
111
|
+
(async () => { const response = await handler(new Request(`http://127.0.0.1:${port}/twin`)); await response?.arrayBuffer(); })(),
|
|
112
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, WARM_TIMEOUT_MS); timer.unref?.(); }),
|
|
113
|
+
]);
|
|
114
|
+
} catch { /* a warm-up that fails leaves its twin as it was */ } finally { if (timer !== undefined) clearTimeout(timer); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Each of `ids` warmed by `warm`, at most WARM_CONCURRENCY at once. */
|
|
118
|
+
async function warmEach(ids: readonly string[], warm: (id: string) => Promise<void>): Promise<void> {
|
|
119
|
+
const queue = [...ids];
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, queue.length) }, async () => {
|
|
121
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift()) await warm(id);
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadFactory(spec: ColocatedTwinSpec): Promise<{ factory: TwinServerFactory; byteStream: boolean; discovery: boolean }> {
|
|
69
126
|
const mod = await importModule(spec.module);
|
|
70
127
|
const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
|
|
71
128
|
if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
|
|
@@ -74,7 +131,7 @@ async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory>
|
|
|
74
131
|
if (typeof factory !== 'function') {
|
|
75
132
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
76
133
|
}
|
|
77
|
-
return factory as TwinServerFactory;
|
|
134
|
+
return { factory: factory as TwinServerFactory, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) };
|
|
78
135
|
}
|
|
79
136
|
|
|
80
137
|
const workerEntry = (): URL => pathToFileURL(siblingScript(import.meta.url, 'host-worker'));
|
|
@@ -100,6 +157,8 @@ export async function startColocatedHost(
|
|
|
100
157
|
const onEvent = options.onEvent ?? (() => {});
|
|
101
158
|
const ports: Record<string, number> = {};
|
|
102
159
|
for (const spec of specs) ports[spec.id] = spec.port;
|
|
160
|
+
const streams: string[] = [];
|
|
161
|
+
const discoverable: string[] = [];
|
|
103
162
|
|
|
104
163
|
if (isolation === 'shared') {
|
|
105
164
|
// Keep one twin's stray async rejection / uncaught throw from killing every other twin in
|
|
@@ -128,7 +187,9 @@ export async function startColocatedHost(
|
|
|
128
187
|
};
|
|
129
188
|
try {
|
|
130
189
|
await parallelStartup(specs, async (spec) => {
|
|
131
|
-
const factory = await loadFactory(spec);
|
|
190
|
+
const { factory, byteStream, discovery } = await loadFactory(spec);
|
|
191
|
+
if (byteStream) streams.push(spec.id);
|
|
192
|
+
if (discovery) discoverable.push(spec.id);
|
|
132
193
|
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
133
194
|
// Record the handle before validating it, so rollback also owns a bad factory result.
|
|
134
195
|
servers.push(server);
|
|
@@ -148,6 +209,9 @@ export async function startColocatedHost(
|
|
|
148
209
|
return {
|
|
149
210
|
isolation,
|
|
150
211
|
ports,
|
|
212
|
+
streams,
|
|
213
|
+
discoverable,
|
|
214
|
+
warm: () => warmEach(discoverable, (id) => warmInProcess(ports[id]!)),
|
|
151
215
|
stop: async () => {
|
|
152
216
|
// Every stop runs, even when a sibling hangs or throws. Host shutdown
|
|
153
217
|
// still resolves within its budget so the owning runtime can retire it.
|
|
@@ -169,13 +233,18 @@ export async function startColocatedHost(
|
|
|
169
233
|
// below would emit a 2nd `spawn` for the same twin, which the isolation test asserts against.
|
|
170
234
|
onEvent({ twin: spec.id, kind: 'spawn', detail: 'worker started' });
|
|
171
235
|
let ready = false;
|
|
172
|
-
worker.on('message', (msg: { type?: string; port?: number }) => {
|
|
236
|
+
worker.on('message', (msg: { type?: string; port?: number; byteStream?: boolean; discovery?: boolean }) => {
|
|
173
237
|
if (msg?.type === 'ready') {
|
|
174
238
|
if (typeof msg.port !== 'number' || !Number.isInteger(msg.port) || msg.port < 1 || msg.port > 65535 || (spec.port !== 0 && msg.port !== spec.port)) {
|
|
175
239
|
rejectReady(new Error(`Twin "${spec.id}" bound port ${msg.port}, requested ${spec.port}.`)); return;
|
|
176
240
|
}
|
|
177
241
|
ports[spec.id] = msg.port;
|
|
178
|
-
|
|
242
|
+
if (msg.byteStream === true) streams.push(spec.id);
|
|
243
|
+
if (msg.discovery === true) discoverable.push(spec.id);
|
|
244
|
+
ready = true;
|
|
245
|
+
// the same progress, and the same start time for the receipt (host-cli.ts), as a shared twin's start
|
|
246
|
+
onEvent({ twin: spec.id, kind: 'started', detail: `port ${msg.port} at ${Math.round(performance.now())} ms` });
|
|
247
|
+
resolveReady();
|
|
179
248
|
}
|
|
180
249
|
});
|
|
181
250
|
worker.on('error', (err) => {
|
|
@@ -205,6 +274,19 @@ export async function startColocatedHost(
|
|
|
205
274
|
return {
|
|
206
275
|
isolation,
|
|
207
276
|
ports,
|
|
277
|
+
streams,
|
|
278
|
+
discoverable,
|
|
279
|
+
// a worker twin is warmed in its own thread, where its server's handler is: asked, and answered once done
|
|
280
|
+
warm: () => warmEach(discoverable, (id) => new Promise<void>((resolve) => {
|
|
281
|
+
const worker = workers.get(id);
|
|
282
|
+
if (!worker) { resolve(); return; }
|
|
283
|
+
const done = (): void => { clearTimeout(timer); worker.off('message', warmed); worker.off('exit', done); resolve(); };
|
|
284
|
+
const warmed = (msg: { type?: string }): void => { if (msg?.type === 'warmed') done(); };
|
|
285
|
+
const timer = setTimeout(done, WARM_TIMEOUT_MS);
|
|
286
|
+
worker.on('message', warmed);
|
|
287
|
+
worker.once('exit', done);
|
|
288
|
+
try { worker.postMessage({ type: 'warm' }); } catch { done(); }
|
|
289
|
+
})),
|
|
208
290
|
stop: async () => {
|
|
209
291
|
stopping.value = true;
|
|
210
292
|
await Promise.all([...workers.values()].map((w) => w.terminate()));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { goMysqlBinding, goMysqlEndpoint, type GoMysqlBinding } from './go-mysql-binding.ts';
|
|
2
|
+
import * as managedOriginalInputs from '@volter/world-core/managed-original-inputs';
|
|
3
|
+
// Definition metadata is independent of database host lifetimes.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { nativeMariaDBReference } from './mariadb-supplier.ts';
|
|
6
|
+
import { mariaDBCredentials } from './mariadb-bootstrap.ts';
|
|
7
|
+
import type { NativeMariaDBService } from './native-mariadb-backing.ts';
|
|
8
|
+
|
|
9
|
+
export interface InfraService { kind: string; hostPort: number; nativeMariaDB?: NativeMariaDBService; virtualTcpEndpoint?: { version: 1; host: string; port: number; targetPort: number }; connectionBindings?: Record<string, GoMysqlBinding & { containerPort: number }>; originalInputs?: managedOriginalInputs.OriginalInputReference; sides?: Record<string, { hostPort: number; containerPort: number }>; configuration?: Record<string, unknown>; replicaSet?: string; postgres?: { user: string; database: string; password?: string } }
|
|
10
|
+
|
|
11
|
+
/** Read the generated definition's loopback port mappings and backing-independent options.
|
|
12
|
+
* The Compose extension carries a MongoDB replica-set name. Older generated commands remain readable. */
|
|
13
|
+
export function parseInfraDefinition(text: string): InfraService[] {
|
|
14
|
+
const { parse } = createRequire(import.meta.url)('yaml') as typeof import('yaml');
|
|
15
|
+
const definition = parse(text);
|
|
16
|
+
const services: InfraService[] = [];
|
|
17
|
+
for (const [kind, value] of Object.entries(definition?.services ?? {})) {
|
|
18
|
+
const declared = value as { image?: unknown; ports?: unknown[]; command?: unknown; entrypoint?: unknown; volumes?: unknown; 'x-volter-native-server'?: unknown; 'x-volter-replica-set'?: unknown; environment?: Record<string, unknown>; 'x-volter-interfaces'?: unknown; 'x-volter-side-ports'?: unknown; 'x-volter-configuration'?: unknown; 'x-volter-original-inputs'?: unknown; 'x-volter-issued-dsn-bindings'?: unknown; 'x-volter-virtual-tcp-endpoint'?: unknown };
|
|
19
|
+
const nativeReference = declared['x-volter-native-server'] === undefined ? undefined : nativeMariaDBReference(declared['x-volter-native-server']);
|
|
20
|
+
if (nativeReference && (kind !== 'mysql' || declared['x-volter-interfaces'] !== 3 || typeof declared.image !== 'string'
|
|
21
|
+
|| !/^(?:docker\.io\/)?(?:library\/)?mariadb(?::[^@]+)?@sha256:[a-f0-9]{64}$/u.test(declared.image)))
|
|
22
|
+
throw new Error('Managed native MariaDB needs mysql, infrastructure interface3 and its exact declared image');
|
|
23
|
+
const originalInputs = declared['x-volter-original-inputs'] === undefined ? undefined : managedOriginalInputs.referenceOf(declared['x-volter-original-inputs']);
|
|
24
|
+
if (originalInputs && declared['x-volter-interfaces'] !== 2) throw new Error('Managed original inputs require infrastructure interface2');
|
|
25
|
+
const option = declared['x-volter-replica-set'];
|
|
26
|
+
if (option !== undefined && (kind !== 'mongodb' || typeof option !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(option))) {
|
|
27
|
+
throw new Error('World-managed replica-set metadata must name one valid MongoDB replica set');
|
|
28
|
+
}
|
|
29
|
+
let replicaSet = option as string | undefined;
|
|
30
|
+
if (kind === 'mongodb' && declared.command !== undefined) {
|
|
31
|
+
const command = declared.command;
|
|
32
|
+
if (!Array.isArray(command) || command.length !== 3 || command[0] !== 'mongod' || command[1] !== '--replSet'
|
|
33
|
+
|| typeof command[2] !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(command[2])) {
|
|
34
|
+
throw new Error('World-managed MongoDB command must be ["mongod", "--replSet", "<name>"] or absent for standalone');
|
|
35
|
+
}
|
|
36
|
+
if (replicaSet !== undefined && replicaSet !== command[2]) throw new Error('World-managed MongoDB command and replica-set metadata disagree');
|
|
37
|
+
replicaSet = command[2];
|
|
38
|
+
}
|
|
39
|
+
const mapped = (declared.ports ?? []).flatMap((port) => {
|
|
40
|
+
const match = typeof port === 'string' ? port.match(/^127\.0\.0\.1:(\d+):(\d+)$/) : null;
|
|
41
|
+
if ((kind === 'clickhouse' || nativeReference) && (!match || ![Number(match[1]), Number(match[2])].every(value => Number.isInteger(value) && value > 0 && value <= 65535))) throw new Error(`managed infrastructure ${kind} has an invalid loopback port`);
|
|
42
|
+
// Other kinds retain their earlier reader: unrecognized mappings are skipped.
|
|
43
|
+
return match ? [{ hostPort: Number(match[1]), containerPort: Number(match[2]) }] : [];
|
|
44
|
+
});
|
|
45
|
+
let sides: InfraService['sides'], configuration: InfraService['configuration'];
|
|
46
|
+
if (kind === 'clickhouse') {
|
|
47
|
+
if (declared['x-volter-interfaces'] !== (originalInputs ? 2 : 1) || mapped.length !== 2 || mapped[0]!.containerPort !== 8123 || mapped[1]!.containerPort !== 9000 || mapped[0]!.hostPort === mapped[1]!.hostPort)
|
|
48
|
+
throw new Error('Managed ClickHouse needs named-interface version1, HTTP8123 and native9000 mappings');
|
|
49
|
+
const raw = declared['x-volter-side-ports'] as Record<string, unknown> | undefined;
|
|
50
|
+
const native = raw?.native as { hostPort?: unknown; containerPort?: unknown } | undefined;
|
|
51
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).join() !== 'native' || !native || native.hostPort !== mapped[1]!.hostPort || native.containerPort !== 9000)
|
|
52
|
+
throw new Error('Managed ClickHouse native side metadata differs from its declared port');
|
|
53
|
+
sides = { native: mapped[1]! };
|
|
54
|
+
const input = declared['x-volter-configuration'];
|
|
55
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Managed ClickHouse needs explicit operator configuration');
|
|
56
|
+
configuration = input as Record<string, unknown>;
|
|
57
|
+
} else if (mapped.length !== 1 || declared['x-volter-interfaces'] !== (nativeReference ? 3 : originalInputs ? 2 : undefined) || declared['x-volter-side-ports'] !== undefined || declared['x-volter-configuration'] !== undefined) {
|
|
58
|
+
throw new Error(`managed infrastructure ${kind} needs exactly one declared loopback port and no multiwire metadata`);
|
|
59
|
+
}
|
|
60
|
+
const ports = mapped.map(port => port.hostPort);
|
|
61
|
+
let nativeMariaDB: NativeMariaDBService | undefined;
|
|
62
|
+
if (nativeReference) {
|
|
63
|
+
if (mapped[0]!.containerPort !== 3306 || originalInputs || declared.command !== undefined || declared.entrypoint !== undefined)
|
|
64
|
+
throw new Error('Managed native MariaDB has an unconsumed port, original-input, command or entrypoint requirement');
|
|
65
|
+
const volumes = declared.volumes;
|
|
66
|
+
if (volumes !== undefined && (!Array.isArray(volumes) || volumes.length !== 1
|
|
67
|
+
|| volumes[0] !== '${VOLTER_WORLD_DATA:?}/native-mariadb-data:/var/lib/mysql'))
|
|
68
|
+
throw new Error('Managed native MariaDB has an unconsumed source mount requirement');
|
|
69
|
+
nativeMariaDB = { hostPort: ports[0]!, image: declared.image as string,
|
|
70
|
+
reference: nativeReference, credentials: mariaDBCredentials(declared.environment ?? {}) };
|
|
71
|
+
}
|
|
72
|
+
const virtual = declared['x-volter-virtual-tcp-endpoint'] as Record<string, unknown> | undefined;
|
|
73
|
+
let virtualTcpEndpoint: InfraService['virtualTcpEndpoint'];
|
|
74
|
+
if (virtual !== undefined) {
|
|
75
|
+
if (!virtual || typeof virtual !== 'object' || Array.isArray(virtual) || Object.keys(virtual).sort().join() !== 'host,port,targetPort,version'
|
|
76
|
+
|| virtual.version !== 1 || typeof virtual.host !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}\.world\.volter\.internal$/.test(virtual.host)
|
|
77
|
+
|| virtual.port !== mapped[0]!.containerPort || virtual.targetPort !== mapped[0]!.hostPort) throw new Error(`Managed ${kind} virtual TCP endpoint differs from its owned listener`);
|
|
78
|
+
virtualTcpEndpoint = { version: 1, host: virtual.host, port: Number(virtual.port), targetPort: Number(virtual.targetPort) };
|
|
79
|
+
}
|
|
80
|
+
let postgres: InfraService['postgres'];
|
|
81
|
+
if (kind === 'postgres') {
|
|
82
|
+
const environment = declared.environment ?? {};
|
|
83
|
+
const user = environment.POSTGRES_USER ?? 'postgres';
|
|
84
|
+
const database = environment.POSTGRES_DB ?? user;
|
|
85
|
+
const password = environment.POSTGRES_PASSWORD;
|
|
86
|
+
if ([user, database, password].some(value => value !== undefined && (typeof value !== 'string' || !value || /[\r\n\0]|\$\{/.test(value)))) {
|
|
87
|
+
throw new Error('World-managed PGlite requires literal POSTGRES_USER, POSTGRES_DB and POSTGRES_PASSWORD');
|
|
88
|
+
}
|
|
89
|
+
if ([user, database].some(value => Buffer.byteLength(value as string, 'utf8') > 63)) throw new Error('World-managed PGlite identifiers must fit PostgreSQL\'s 63-byte name limit');
|
|
90
|
+
postgres = { user: user as string, database: database as string, ...(password !== undefined ? { password: password as string } : {}) };
|
|
91
|
+
}
|
|
92
|
+
const typed = declared['x-volter-issued-dsn-bindings'];
|
|
93
|
+
let connectionBindings: Record<string, GoMysqlBinding & { containerPort: number }> | undefined;
|
|
94
|
+
if (typed !== undefined) {
|
|
95
|
+
if (kind !== 'mysql' || !typed || typeof typed !== 'object' || Array.isArray(typed)) throw new Error('Managed DSN bindings need a declared MySQL service');
|
|
96
|
+
connectionBindings = Object.create(null);
|
|
97
|
+
for (const [name, value] of Object.entries(typed)) {
|
|
98
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid managed DSN binding');
|
|
99
|
+
const row = value as Record<string, unknown>;
|
|
100
|
+
const parsed = goMysqlBinding({ ...row, scheme: 'mysql' }, 6, name, mapped[0]!.containerPort);
|
|
101
|
+
if (!parsed.dsnFormat || Object.keys(row).some(key => !['dsnFormat', 'endpoint', 'dsnPrefix', 'dsnProtocol', 'containerPort', 'query'].includes(key))) throw new Error(`Invalid managed DSN declaration for ${name}`);
|
|
102
|
+
connectionBindings![name] = { ...parsed as GoMysqlBinding, containerPort: mapped[0]!.containerPort };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
services.push({ kind, ...(nativeMariaDB ? { nativeMariaDB } : {}), ...(virtualTcpEndpoint ? { virtualTcpEndpoint } : {}), ...(connectionBindings ? { connectionBindings } : {}), ...(originalInputs ? { originalInputs } : {}), hostPort: ports[0]!, ...(sides ? { sides } : {}), ...(configuration ? { configuration } : {}), ...(replicaSet ? { replicaSet } : {}), ...(postgres ? { postgres } : {}) });
|
|
106
|
+
}
|
|
107
|
+
return services;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Endpoints of the declared infrastructure, published through external.discover. Only URLs
|
|
111
|
+
* whose protocol and loopback port match a declared service belong to this lifecycle. */
|
|
112
|
+
export function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string> {
|
|
113
|
+
const connections: Record<string, string> = {};
|
|
114
|
+
for (const [name, value] of Object.entries(env)) {
|
|
115
|
+
if (value === undefined) continue;
|
|
116
|
+
const typed = services.filter(service => service.connectionBindings?.[name] !== undefined);
|
|
117
|
+
if (typed.length > 1) throw new Error(`Managed DSN ${name} belongs to multiple infrastructure services`);
|
|
118
|
+
if (typed.length === 1) {
|
|
119
|
+
const service = typed[0]!, endpoint = goMysqlEndpoint(value, service.connectionBindings![name]!, service.connectionBindings![name]!.containerPort);
|
|
120
|
+
const virtual = service.virtualTcpEndpoint, binding = service.connectionBindings![name]!;
|
|
121
|
+
const expectedHost = binding.endpoint === 'default-port' && virtual ? virtual.host : '127.0.0.1';
|
|
122
|
+
const expectedPort = binding.endpoint === 'default-port' && virtual ? virtual.port : service.hostPort;
|
|
123
|
+
if (!endpoint || endpoint.host !== expectedHost || endpoint.port !== expectedPort) throw new Error(`Managed DSN ${name} does not match its owned endpoint`);
|
|
124
|
+
connections[name] = value;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(value);
|
|
129
|
+
if (url.hostname !== '127.0.0.1') continue;
|
|
130
|
+
const kind = url.protocol === 'postgresql:' ? 'postgres' : url.protocol.slice(0, -1);
|
|
131
|
+
if (services.some((service) => (service.kind === kind && service.hostPort === Number(url.port)) || (service.kind === 'clickhouse' && ((url.protocol === 'http:' && service.hostPort === Number(url.port)) || (url.protocol === 'clickhouse:' && service.sides?.native?.hostPort === Number(url.port)))))) connections[name] = value;
|
|
132
|
+
} catch { /* not an infrastructure endpoint */ }
|
|
133
|
+
}
|
|
134
|
+
return connections;
|
|
135
|
+
}
|