@volter/world 2.0.0
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/README.md +27 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +669 -0
- package/dist/src/credentials.d.ts +12 -0
- package/dist/src/credentials.js +64 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +6 -0
- package/dist/src/locate.d.ts +18 -0
- package/dist/src/locate.js +60 -0
- package/dist/src/serve-shutdown.d.ts +6 -0
- package/dist/src/serve-shutdown.js +27 -0
- package/dist/src/world.d.ts +299 -0
- package/dist/src/world.js +491 -0
- package/package.json +37 -0
- package/src/cli.ts +486 -0
- package/src/credentials.ts +56 -0
- package/src/handlers-in-repo.test.ts +45 -0
- package/src/index.ts +6 -0
- package/src/journeys/kit.ts +12 -0
- package/src/journeys/tutorial-runner.test.ts +180 -0
- package/src/journeys/tutorial.ts +345 -0
- package/src/journeys/tutorials.test.ts +39 -0
- package/src/locate.ts +61 -0
- package/src/sdk.test.ts +92 -0
- package/src/serve-shutdown.test.ts +40 -0
- package/src/serve-shutdown.ts +26 -0
- package/src/world.ts +448 -0
package/src/sdk.test.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The vocabulary made executable: a World's
|
|
2
|
+
// verbs over a booted world — every method a name for a core or kernel function.
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
import { mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { parseOriginUrl, requireToken, storeToken, tokenFor, World } from './index.ts';
|
|
8
|
+
import { useWorldCleanup, worlds } from '../../world-runtime/src/runtime-test-support.ts';
|
|
9
|
+
|
|
10
|
+
const AT = '2026-09-05T12:00:00.000Z';
|
|
11
|
+
useWorldCleanup();
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
describe('World — the twins an app needs, running together', () => {
|
|
15
|
+
const SERVER = "require('node:http').createServer((_req,res)=>res.end('ok')).listen(Number(process.env.PORT),'127.0.0.1')";
|
|
16
|
+
/** An app root holding `.volter/world.json` — what `volter world init` writes, with process twins here. */
|
|
17
|
+
function appRoot(id: string): string {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'sdk-world-'));
|
|
19
|
+
mkdirSync(join(root, '.volter'), { recursive: true });
|
|
20
|
+
writeFileSync(join(root, '.volter', 'world.json'), JSON.stringify({ id, services: ['stripe', 'slack'].map((service) => ({ id: service, type: 'process', command: 'node', args: ['-e', SERVER], env: { NODE_OPTIONS: '' }, portArg: false, rootArg: false })) }));
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('open from the cwd, up, status, changes through a repo, log, changeset with a message, branch, checkout, down resumes', async () => {
|
|
25
|
+
const root = appRoot('sdk-w');
|
|
26
|
+
// the world is found from any directory under the root, on its main branch
|
|
27
|
+
mkdirSync(join(root, 'src', 'deep'), { recursive: true });
|
|
28
|
+
expect(World.find(join(root, 'src', 'deep'))?.root).toBe(root);
|
|
29
|
+
expect(World.find(mkdtempSync(join(tmpdir(), 'sdk-nowhere-')))).toBeNull();
|
|
30
|
+
const world = World.open({ root });
|
|
31
|
+
expect(world.name).toBe('sdk-w');
|
|
32
|
+
expect(world.status()).toMatchObject({ running: false, origin: null, unpushed: 0, branches: [] });
|
|
33
|
+
|
|
34
|
+
await world.up({ seed: false });
|
|
35
|
+
worlds.push({ root, name: 'sdk-w' });
|
|
36
|
+
expect(world.status()).toMatchObject({ running: true, branch: 'sdk-w', branches: ['sdk-w'] });
|
|
37
|
+
expect(world.status().services.stripe?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
38
|
+
expect(world.status().envFile).toBe(join(root, '.volter', 'world.env'));
|
|
39
|
+
|
|
40
|
+
const stripe = world.repo('stripe');
|
|
41
|
+
await stripe.change({ operation: 'price.create', subjectType: 'price', subjectId: 'price_annual', fields: { unit_amount: 47000 }, occurredAt: AT, actor: { kind: 'agent' } });
|
|
42
|
+
expect(world.repos().map((r) => r.service).sort()).toEqual(['stripe']); // a twin appears once it has a ledger
|
|
43
|
+
expect(world.diff().total).toBe(1);
|
|
44
|
+
expect(world.log().map((e) => [e.service, e.operation, e.subject.id])).toEqual([['stripe', 'price.create', 'price_annual']]);
|
|
45
|
+
expect(world.status().unpushed).toBe(1);
|
|
46
|
+
// the changeset carries the author's message beside the generated summary, outside the hash
|
|
47
|
+
const cs = world.changeset({ message: 'annual price for the launch' });
|
|
48
|
+
expect(cs.name).toBe('annual-price-for-the-launch'); expect(cs.message).toBe('annual price for the launch'); // named from the message expect(cs.narration).toContain('price');
|
|
49
|
+
expect(cs.actions).toHaveLength(1); expect(world.changesets().map((c) => c.changeset.name)).toEqual(['annual-price-for-the-launch']);
|
|
50
|
+
expect(world.status().changesets).toEqual({ total: 1, pushed: 0 });
|
|
51
|
+
expect(world.readiness('annual-price-for-the-launch').ready).toBe(false);
|
|
52
|
+
expect(() => world.repo('nope')).toThrow(/has no twin "nope"/);
|
|
53
|
+
// the default data is the only origin: nothing to push to, nothing to fetch from
|
|
54
|
+
expect(world.origin()).toBeNull();
|
|
55
|
+
await expect(world.push()).rejects.toThrow(/default data cannot be pushed to/);
|
|
56
|
+
expect(() => world.fetch()).toThrow(/only origin is the default data/);
|
|
57
|
+
|
|
58
|
+
// a branch over the same mirrors with its own log, checked out; checkout switches back
|
|
59
|
+
const feature = await world.branch('feature');
|
|
60
|
+
worlds.push({ root, name: 'feature' });
|
|
61
|
+
expect(World.open({ root }).name).toBe('feature'); // .volter/current
|
|
62
|
+
expect(feature.repo('stripe').unpushed()).toEqual([]); // the base's unpushed change stays on the base
|
|
63
|
+
await feature.repo('stripe').change({ operation: 'price.update', subjectType: 'price', subjectId: 'price_annual', fields: { unit_amount: 1 }, occurredAt: AT, actor: { kind: 'agent' } });
|
|
64
|
+
const back = await feature.checkout('sdk-w');
|
|
65
|
+
expect(back.name).toBe('sdk-w'); expect(World.open({ root }).name).toBe('sdk-w');
|
|
66
|
+
expect(back.status().running).toBe(true); expect(World.open({ root, name: 'feature' }).status().running).toBe(false);
|
|
67
|
+
expect(back.unpushed()).toHaveLength(1);
|
|
68
|
+
await expect(back.checkout('nonesuch')).rejects.toThrow(/No branch "nonesuch"/);
|
|
69
|
+
|
|
70
|
+
// down stops compute; up resumes the branch WITH its state (reset is the way back to the defaults)
|
|
71
|
+
await back.down();
|
|
72
|
+
expect(back.status().running).toBe(false);
|
|
73
|
+
await back.up();
|
|
74
|
+
expect(back.status().running).toBe(true);
|
|
75
|
+
expect(back.unpushed()).toHaveLength(1);
|
|
76
|
+
await back.down();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a remote URL names one world; tokens live in the user\'s config dir, never in the repo', () => {
|
|
80
|
+
expect(parseOriginUrl('https://twins.example.com/acme/web')).toEqual({ url: 'https://twins.example.com', namespace: 'acme/web' });
|
|
81
|
+
expect(() => parseOriginUrl('https://twins.example.com/acme')).toThrow(/names one world/);
|
|
82
|
+
expect(() => parseOriginUrl('not a url')).toThrow(/Not a remote URL/);
|
|
83
|
+
const store = join(mkdtempSync(join(tmpdir(), 'sdk-cred-')), 'credentials.json');
|
|
84
|
+
expect(tokenFor('https://twins.example.com', store)).toBeUndefined();
|
|
85
|
+
expect(() => requireToken('https://twins.example.com', undefined, store)).toThrow(/No token stored/);
|
|
86
|
+
storeToken('https://twins.example.com/', 'tok_read', store);
|
|
87
|
+
expect(tokenFor('https://twins.example.com', store)).toBe('tok_read');
|
|
88
|
+
expect(requireToken('https://twins.example.com', undefined, store)).toBe('tok_read');
|
|
89
|
+
expect(requireToken('https://twins.example.com', 'tok_ci', store)).toBe('tok_ci'); // an explicit token wins for one call
|
|
90
|
+
expect((statSync(store).mode & 0o777)).toBe(0o600);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { waitForServeShutdown } from './serve-shutdown.ts';
|
|
4
|
+
|
|
5
|
+
test('repeated signals join one asynchronous shutdown and listeners are removed', async () => {
|
|
6
|
+
const signals = new EventEmitter();
|
|
7
|
+
let calls = 0;
|
|
8
|
+
let release!: () => void;
|
|
9
|
+
const stopped = new Promise<void>(resolve => { release = resolve; });
|
|
10
|
+
const shutdown = waitForServeShutdown(() => { calls++; return stopped; }, { signals });
|
|
11
|
+
let finished = false;
|
|
12
|
+
shutdown.then(() => { finished = true; });
|
|
13
|
+
signals.emit('SIGTERM'); signals.emit('SIGHUP'); signals.emit('SIGINT');
|
|
14
|
+
await Promise.resolve();
|
|
15
|
+
expect(calls).toBe(1);
|
|
16
|
+
expect(finished).toBe(false);
|
|
17
|
+
release(); await shutdown;
|
|
18
|
+
expect(finished).toBe(true);
|
|
19
|
+
expect(signals.eventNames()).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a failed stop rejects the awaited shutdown', async () => {
|
|
23
|
+
const signals = new EventEmitter();
|
|
24
|
+
const shutdown = waitForServeShutdown(async () => { throw new Error('external teardown failed'); }, { signals });
|
|
25
|
+
signals.emit('SIGTERM');
|
|
26
|
+
await expect(shutdown).rejects.toThrow('external teardown failed');
|
|
27
|
+
expect(signals.eventNames()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the shutdown deadline fails and a late stop rejection remains handled', async () => {
|
|
31
|
+
const signals = new EventEmitter();
|
|
32
|
+
let fail!: (error: Error) => void;
|
|
33
|
+
const stopped = new Promise<void>((_, reject) => { fail = reject; });
|
|
34
|
+
const shutdown = waitForServeShutdown(() => stopped, { signals, timeoutMs: 10 });
|
|
35
|
+
signals.emit('SIGTERM');
|
|
36
|
+
await expect(shutdown).rejects.toThrow('cleanup incomplete');
|
|
37
|
+
expect(signals.eventNames()).toEqual([]);
|
|
38
|
+
fail(new Error('late stop failure'));
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/** Signals request one shutdown; repeated signals never start a competing World teardown. */
|
|
4
|
+
export async function waitForServeShutdown(stop: () => Promise<void>, options: {
|
|
5
|
+
signals?: Pick<EventEmitter, 'on' | 'off'>; timeoutMs?: number;
|
|
6
|
+
} = {}): Promise<void> {
|
|
7
|
+
const signals = options.signals ?? process;
|
|
8
|
+
const names = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
|
|
9
|
+
let started = false;
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
let request!: () => void;
|
|
12
|
+
try {
|
|
13
|
+
await new Promise<void>((resolve, reject) => {
|
|
14
|
+
request = () => {
|
|
15
|
+
if (started) return;
|
|
16
|
+
started = true;
|
|
17
|
+
timer = setTimeout(() => reject(new Error('World serve shutdown timed out; cleanup incomplete, instance and reservation retained for explicit down')), options.timeoutMs ?? 8000);
|
|
18
|
+
Promise.resolve().then(stop).then(resolve, reject);
|
|
19
|
+
};
|
|
20
|
+
for (const name of names) signals.on(name, request);
|
|
21
|
+
});
|
|
22
|
+
} finally {
|
|
23
|
+
if (timer) clearTimeout(timer);
|
|
24
|
+
for (const name of names) signals.off(name, request);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/world.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// WORLD — the twins an app needs, running together (docs/concepts/the-model.md,
|
|
2
|
+
// docs/concepts/worlds.md#the-config-and-the-running-world): a branch with compute attached. `World.open()` finds the world of the cwd the
|
|
3
|
+
// way git finds its repository; every method is a name for a world-runtime verb, in the words a
|
|
4
|
+
// user reads. The `volter` command is one client of this class and adds nothing.
|
|
5
|
+
import { rebaseChangeset, worldBootMarker, type Changeset, type ChangesetApplication, type LedgerDelta, type PerformAction, type RemoteExecute, type TwinAction, type WorldMarker, type ApplyReceipt, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources, type TwinResource } from '@volter/world-core';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld,
|
|
9
|
+
statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin,
|
|
10
|
+
deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, findInstalledPackage, type ServedWorld, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom,
|
|
11
|
+
type DeployTwinOutcome, type MaterializedRoot,
|
|
12
|
+
type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance, materializeRoots } from '@volter/world-runtime';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { requireToken, storeToken } from './credentials.ts';
|
|
16
|
+
import { currentBranch, findWorldRoot, mainBranch, requireWorldRoot, setCurrentBranch, worldConfigRelative, worldEnvPath, worldSeedPath } from './locate.ts';
|
|
17
|
+
import { parentEntries, rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core';
|
|
18
|
+
|
|
19
|
+
export type WorldRef = { name?: string; root?: string };
|
|
20
|
+
export type WorldMode = 'local' | 'share' | 'sealed';
|
|
21
|
+
|
|
22
|
+
/** One change as `log` shows it: the write, and the receipt the vendor answered with once pushed. */
|
|
23
|
+
/** A twin's branch log as the world reads it: the twin's name, the state service it records under, its control root. */
|
|
24
|
+
export class TwinLog {
|
|
25
|
+
constructor(readonly service: string, readonly stateService: string, readonly root: string) {}
|
|
26
|
+
/** this branch's own entries, bookkeeping aside */
|
|
27
|
+
log(): TwinAction[] { return listActions(this.stateService, this.root).filter((a) => !isTwinBookkeeping(a)); }
|
|
28
|
+
/** entries the parent does not hold */
|
|
29
|
+
unpushed(opts: { pushable?: boolean } = {}): TwinAction[] { return opts.pushable ? pushablePendingActions(this.stateService, this.root) : pendingActions(this.stateService, this.root); }
|
|
30
|
+
/** the tree: this twin's resources as a read sees them */
|
|
31
|
+
state(): TwinResource[] { return twinResources(this.stateService, this.root); }
|
|
32
|
+
/** one write through the kernel's write path (the head performs it when this twin's root says so) */
|
|
33
|
+
change(write: Parameters<typeof applyTwinWrite>[1]): ReturnType<typeof applyTwinWrite> { return applyTwinWrite(this.stateService, write, this.root); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type LogEntry = TwinAction & { receipt?: ApplyReceipt; changeset?: string; /** v2: the receipt on the landed copy — deployed, refused, failed, landed */ landed?: Receipt; /** the entry's POSITION in its twin's whole log (contract "Just like Neon", 2): the parent view then the branch, counted from one */ position?: number };
|
|
37
|
+
|
|
38
|
+
export type WorldStatus = {
|
|
39
|
+
world: string;
|
|
40
|
+
root: string;
|
|
41
|
+
branch: string;
|
|
42
|
+
branches: string[];
|
|
43
|
+
running: boolean;
|
|
44
|
+
/** the URL this world is served at, when a serve process is alive (contract "Just like Neon", 4) */
|
|
45
|
+
served: { base: string; pid: number; startedAt: string } | null;
|
|
46
|
+
/** the origin this world clones from and pushes to, or null: the default data is its only origin */
|
|
47
|
+
origin: { url: string; namespace: string; fetchedAt?: string } | null;
|
|
48
|
+
unpushed: number;
|
|
49
|
+
changesets: { total: number; pushed: number };
|
|
50
|
+
services: Record<string, { url?: string; running: boolean; /** the pack's protocol major and its standing (protocol 1 is deprecated: served under a warning, its vendor half throws) */ protocol?: { major: number; standing: string } }>;
|
|
51
|
+
envFile?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** `https://host/org/world` → the remote's base URL and the namespace it addresses. */
|
|
55
|
+
export function parseOriginUrl(url: string): { url: string; namespace: string } {
|
|
56
|
+
// a PATH is a world on this machine (git's file transport): its directory, and its served name
|
|
57
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
58
|
+
const root = resolve(url);
|
|
59
|
+
const configPath = join(root, '.volter', 'world.json');
|
|
60
|
+
if (!existsSync(configPath)) throw new Error(`Not a remote URL or world directory: ${url} (want https://<host>/<org>/<world>, or a directory holding .volter/world.json)`);
|
|
61
|
+
const config = loadWorldConfig(configPath, root).config;
|
|
62
|
+
return { url: root, namespace: config.bare?.name ?? `${config.id}/${config.id}` };
|
|
63
|
+
}
|
|
64
|
+
let parsed: URL;
|
|
65
|
+
try { parsed = new URL(url); } catch { throw new Error(`Not a remote URL: ${url} (want https://<host>/<org>/<world>)`); }
|
|
66
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
67
|
+
if (parts.length !== 2) throw new Error(`A remote URL names one world: https://<host>/<org>/<world>, got ${url}`);
|
|
68
|
+
return { url: `${parsed.origin}`, namespace: `${parts[0]}/${parts[1]}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class World {
|
|
72
|
+
/** The branch this handle is on — the instance name in the kernel. */
|
|
73
|
+
readonly name: string;
|
|
74
|
+
/** The world root: the app repo. */
|
|
75
|
+
readonly root: string;
|
|
76
|
+
private constructor(name: string, root: string) { this.name = name; this.root = root; }
|
|
77
|
+
|
|
78
|
+
// ── finding and making worlds ───────────────────────────────────────────────────────────
|
|
79
|
+
/** The world of the cwd (or of `ref.root`), on its checked-out branch (or `ref.name`). */
|
|
80
|
+
/** The config `loadWorldConfig` resolves for this world: the in-repo world.json, else the world's name. */
|
|
81
|
+
private configRef(): string { const inRepo = join(this.root, '.volter', 'world.json'); return existsSync(inRepo) ? inRepo : this.name; }
|
|
82
|
+
|
|
83
|
+
static open(ref: WorldRef = {}): World {
|
|
84
|
+
const root = ref.root === undefined ? requireWorldRoot() : requireWorldRoot(ref.root);
|
|
85
|
+
return new World(ref.name ?? currentBranch(root), root);
|
|
86
|
+
}
|
|
87
|
+
/** Whether `from` (default: the cwd) is inside a world. */
|
|
88
|
+
static find(from?: string): World | null {
|
|
89
|
+
const root = findWorldRoot(from);
|
|
90
|
+
return root === null ? null : new World(currentBranch(root), root);
|
|
91
|
+
}
|
|
92
|
+
/** `volter world init --bare <org>/<world> --twins a,b`: a world with no app — a package.json naming the
|
|
93
|
+
* twins (installed with bun), then the world over them, served as `/<org>/<world>/`. */
|
|
94
|
+
static initBare(dir: string, served: string, twins: string[], opts: { install?: boolean; force?: boolean } = {}): { world: World; result: InitResult } {
|
|
95
|
+
if (!/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test(served)) throw new Error(`a bare world is named <org>/<world>, got ${served}`);
|
|
96
|
+
if (twins.length === 0) throw new Error('a bare world needs its twins: --twins github,slack');
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
const pkgPath = join(dir, 'package.json');
|
|
99
|
+
if (!existsSync(pkgPath)) writeFileSync(pkgPath, `${JSON.stringify({ name: served.split('/')[1], private: true, devDependencies: Object.fromEntries(twins.map((t) => [`@volter/twin-${t}`, '*'])) }, null, 2)}\n`);
|
|
100
|
+
if (opts.install !== false && !twins.every((t) => findInstalledPackage(dir, `@volter/twin-${t}`))) {
|
|
101
|
+
// the runtime's own installer: bun under Bun, npm under Node — never assumed
|
|
102
|
+
const cmd = typeof Bun !== 'undefined' ? ['bun', 'add', '-d'] : ['npm', 'install', '--save-dev'];
|
|
103
|
+
const r = spawnSync(cmd[0]!, [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { cwd: dir, stdio: 'inherit' });
|
|
104
|
+
if (r.status !== 0) throw new Error(`installing the twins failed (${cmd.join(' ')} exited ${r.status})`);
|
|
105
|
+
}
|
|
106
|
+
const name = served.split('/')[1]!;
|
|
107
|
+
const result = initWorld(name, dir, { root: dir, vendors: twins, bare: served, force: opts.force ?? false });
|
|
108
|
+
return { world: new World(name, dir), result };
|
|
109
|
+
}
|
|
110
|
+
/** `volter world init`: detect the app's vendors and write `.volter/world.json` beside the code. */
|
|
111
|
+
static init(app: string = process.cwd(), opts: Omit<InitOptions, 'root'> & { name?: string; root?: string } = {}): { world: World; result: InitResult } {
|
|
112
|
+
const name = opts.name ?? worldNameFor(app);
|
|
113
|
+
const { name: _n, root: _r, ...rest } = opts;
|
|
114
|
+
const result = initWorld(name, app, { root: opts.root ?? app, ...rest });
|
|
115
|
+
return { world: new World(name, opts.root ?? app), result };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── lifecycle ───────────────────────────────────────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Start the twins on this branch. A branch that has run before comes back with its state (down
|
|
121
|
+
* stops compute, up resumes it — `reset` is the way back to the default data); a fresh branch
|
|
122
|
+
* boots clean and loads the default data unless `seed: false`.
|
|
123
|
+
*/
|
|
124
|
+
async up(opts: { mode?: WorldMode; seed?: boolean; cwd?: string } = {}): Promise<WorldInstance> {
|
|
125
|
+
const existing = this.instance();
|
|
126
|
+
if (existing?.running) return existing; // already up: nothing to do
|
|
127
|
+
const instance = await upWorld(worldConfigRelative(), {
|
|
128
|
+
name: this.name, root: this.root, envFile: worldEnvPath(this.root),
|
|
129
|
+
...(opts.mode ? { mode: opts.mode } : existing ? { mode: existing.mode } : {}),
|
|
130
|
+
...(existing ? { keepState: true } : {}),
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
if (!existing && opts.seed !== false && existsSync(worldSeedPath(this.root))) {
|
|
134
|
+
// a story that fails is a loud failure: the world is up, but not the world the app expects
|
|
135
|
+
const seeded = await seedWorld(this.name, { root: this.root, cwd: opts.cwd ?? this.root });
|
|
136
|
+
if (seeded.exitCode !== 0) throw new Error(`the story failed (${seeded.entry} exited ${seeded.exitCode}); the world is up without it — fix the seed and \`volter world seed\``);
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
// a root set while the world was stopped reaches the twins' state after the default data (which a
|
|
140
|
+
// fresh boot runs simulated), where the deploy reads it — also when the seed failed
|
|
141
|
+
materializeRoots(loadWorldConfig(this.configRef(), this.root).config, instance.dirs.data, this.root);
|
|
142
|
+
}
|
|
143
|
+
return instance;
|
|
144
|
+
}
|
|
145
|
+
/** Stop the twins; the branch's state stays (`up` resumes). `purge` forgets it. */
|
|
146
|
+
down(opts: { purge?: boolean } = {}): ReturnType<typeof downWorld> { return downWorld(this.name, this.root, opts); }
|
|
147
|
+
/** Run a command inside the world: the app or its tests, with the twins' URLs and fake credentials in its env. */
|
|
148
|
+
run(command: string[], opts: { cwd?: string; verbose?: boolean } = {}): Promise<number> { return runWithWorldEnv(this.name, command, this.root, { ...opts, cwd: opts.cwd ?? this.root }); }
|
|
149
|
+
/** The shell script that activates the world in the current shell: `eval "$(volter world activate)"` — vendor CLIs and curl reach the twins. */
|
|
150
|
+
activateScript(): string { return activateScript(this.name, this.root); }
|
|
151
|
+
/** A subshell with the world active; resolves to its exit code. */
|
|
152
|
+
shell(): Promise<number> { return shellWorld(this.name, this.root); }
|
|
153
|
+
|
|
154
|
+
/** The kernel's instance record, or null before the first `up`. */
|
|
155
|
+
instance(): (WorldInstance & { running: boolean; livePids: number[] }) | null {
|
|
156
|
+
try { return statusWorld(this.name, this.root); } catch { return null; }
|
|
157
|
+
}
|
|
158
|
+
/** The world, the branch, the origin, what is unpushed, what is running. */
|
|
159
|
+
status(): WorldStatus {
|
|
160
|
+
const instance = this.instance();
|
|
161
|
+
const changesets = instance ? this.changesets() : [];
|
|
162
|
+
const services: WorldStatus['services'] = {};
|
|
163
|
+
if (instance) for (const [id, s] of Object.entries(instance.services)) services[id] = { ...(s.url ? { url: s.url } : {}), running: s.type === 'external' || instance.livePids.includes(s.pid), ...(s.protocol ? { protocol: { major: s.protocol.major, standing: s.protocol.standing } } : {}) };
|
|
164
|
+
return {
|
|
165
|
+
world: this.name, root: this.root, branch: this.name,
|
|
166
|
+
branches: listWorlds(this.root).map((w) => w.name).sort(),
|
|
167
|
+
running: instance?.running ?? false,
|
|
168
|
+
served: (() => { const r = readServeRecord(this.root); return r ? { base: r.base, pid: r.pid, startedAt: r.startedAt } : null; })(),
|
|
169
|
+
origin: instance?.origin ? { url: instance.origin.url, namespace: instance.origin.namespace, ...(instance.origin.fetchedAt ? { fetchedAt: instance.origin.fetchedAt } : {}) } : null,
|
|
170
|
+
unpushed: instance ? this.unpushed().length : 0,
|
|
171
|
+
changesets: { total: changesets.length, pushed: changesets.filter((c) => c.changeset.applied?.outcome === 'applied').length },
|
|
172
|
+
services,
|
|
173
|
+
...(instance ? { envFile: instance.envFile } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── the repos ───────────────────────────────────────────────────────────────────────────
|
|
178
|
+
/** One twin log per twin the world runs: its branch entries and what is unpushed. */
|
|
179
|
+
repos(): TwinLog[] { return worldLedgers(this.name, this.root).map((l) => new TwinLog(l.service, l.stateService, l.controlRoot)); }
|
|
180
|
+
repo(service: string): TwinLog {
|
|
181
|
+
const found = this.repos().find((r) => r.service === service);
|
|
182
|
+
if (!found) { const instance = statusWorld(this.name, this.root); if (!instance.services[service]) throw new Error(`World "${this.name}" has no twin "${service}" (has: ${Object.keys(instance.services).sort().join(', ') || 'none'})`); return new TwinLog(service, service, join(instance.dirs.data, service)); }
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── the log ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
/** Every write the app made, across the twins, oldest first, with the receipt against each pushed one. */
|
|
188
|
+
log(): LogEntry[] {
|
|
189
|
+
const receipts = new Map<string, { receipt: ApplyReceipt; changeset: string }>();
|
|
190
|
+
for (const located of this.changesets()) for (const receipt of located.changeset.applied?.receipts ?? []) receipts.set(receipt.actionId, { receipt, changeset: located.changeset.name });
|
|
191
|
+
const rows: LogEntry[] = [];
|
|
192
|
+
for (const repo of this.repos()) {
|
|
193
|
+
// v2 (log.ts): the parent log first — what origin holds, minus the default data (a placeholder
|
|
194
|
+
// row) and minus this branch's own landed copies (shown once, as the branch entry with its receipt)
|
|
195
|
+
const landed = new Map<string, Receipt>();
|
|
196
|
+
const own = new Set(repo.log().map((a) => a.id));
|
|
197
|
+
// the position of each entry in this twin's whole log: the parent view, then the branch
|
|
198
|
+
const positionOf = new Map<string, number>();
|
|
199
|
+
{ let n = 0; for (const e of parentEntries(repo.stateService, repo.root)) positionOf.set(e.id, ++n); for (const e of repo.log()) if (!positionOf.has(e.id)) positionOf.set(e.id, ++n); }
|
|
200
|
+
for (const e of parentEntries(repo.stateService, repo.root)) {
|
|
201
|
+
// the settled receipt wins over a provisional `landed` one: origin's copy says deployed/refused/failed after this branch's own copy said landed
|
|
202
|
+
if (e.landsId && e.receipt) { const prior = landed.get(e.landsId); if (!prior || prior.status === 'landed' || e.receipt.status !== 'landed') landed.set(e.landsId, e.receipt); }
|
|
203
|
+
if (e.landsId && own.has(e.landsId)) continue;
|
|
204
|
+
if (own.has(e.id)) continue;
|
|
205
|
+
if (e.provenance === 'placeholder') continue;
|
|
206
|
+
if (e.op !== 'set' || !e.fields || e.subject.type.startsWith('_')) continue;
|
|
207
|
+
rows.push({ ...(e as unknown as TwinAction), service: repo.service, ...(e.receipt ? { landed: e.receipt } : {}), ...(positionOf.has(e.id) ? { position: positionOf.get(e.id)! } : {}) });
|
|
208
|
+
}
|
|
209
|
+
for (const action of repo.log()) {
|
|
210
|
+
const r = receipts.get(action.id); const l = landed.get(action.id);
|
|
211
|
+
// the row names the TWIN (slack), not the state service it records under (chat)
|
|
212
|
+
const row = { ...action, service: repo.service, ...(positionOf.has(action.id) ? { position: positionOf.get(action.id)! } : {}) };
|
|
213
|
+
rows.push(l ? { ...row, landed: l } : r ? { ...row, receipt: r.receipt, changeset: r.changeset } : row);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return rows.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id));
|
|
217
|
+
}
|
|
218
|
+
/** Changes not yet pushed (`log origin..HEAD`), across the twins. */
|
|
219
|
+
unpushed(): TwinAction[] { return this.repos().flatMap((r) => r.unpushed({ pushable: true })).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); }
|
|
220
|
+
diff(base?: string): LedgerDelta { return diffWorld(this.name, { root: this.root, ...(base ? { base } : {}) }); }
|
|
221
|
+
|
|
222
|
+
// ── default data ────────────────────────────────────────────────────────────────────────
|
|
223
|
+
/** Load the default data: the seed runs with every twin recording what it creates as data that was already there. */
|
|
224
|
+
seed(opts: { entry?: string; cwd?: string } = {}): ReturnType<typeof seedWorld> { return seedWorld(this.name, { root: this.root, cwd: this.root, ...opts }); }
|
|
225
|
+
/** Back to the default data: forget this branch's state, boot, seed. */
|
|
226
|
+
reset(opts: { entry?: string; cwd?: string } = {}): ReturnType<typeof resetWorld> { return resetWorld(this.name, { root: this.root, cwd: this.root, ...opts }); }
|
|
227
|
+
|
|
228
|
+
// ── branches ────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
/**
|
|
230
|
+
* A new branch from here — its own log over this branch's mirrors — checked out. One branch runs
|
|
231
|
+
* at a time in a world (the app's env names one set of twins), so this branch stops as the new
|
|
232
|
+
* one starts; `checkout` brings it back with its state.
|
|
233
|
+
*/
|
|
234
|
+
async branch(name: string, opts: { at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } } = {}): Promise<World> {
|
|
235
|
+
if (listWorlds(this.root).some((w) => w.name === name)) throw new Error(`A branch "${name}" already exists — \`volter world checkout ${name}\` switches to it`);
|
|
236
|
+
await branchWorld(this.name, name, { root: this.root, envFile: worldEnvPath(this.root), ...(opts.at ? { at: opts.at } : {}) });
|
|
237
|
+
if (this.instance()?.running) await downWorld(this.name, this.root);
|
|
238
|
+
setCurrentBranch(this.root, name);
|
|
239
|
+
return new World(name, this.root);
|
|
240
|
+
}
|
|
241
|
+
/** Switch to a branch: its twins come back with their state; this branch's stop. */
|
|
242
|
+
async checkout(name: string): Promise<World> {
|
|
243
|
+
if (name === 'main') name = mainBranch(this.root); // the main branch answers to its name and to `main`
|
|
244
|
+
if (name === this.name) return this;
|
|
245
|
+
if (!listWorlds(this.root).some((w) => w.name === name)) throw new Error(`No branch "${name}" in this world (branches: ${listWorlds(this.root).map((w) => w.name).sort().join(', ') || 'none'})`);
|
|
246
|
+
const wasRunning = this.instance()?.running ?? false;
|
|
247
|
+
if (wasRunning) await downWorld(this.name, this.root);
|
|
248
|
+
const target = new World(name, this.root);
|
|
249
|
+
if (wasRunning && !target.instance()?.running) await checkoutWorld(name, { root: this.root });
|
|
250
|
+
setCurrentBranch(this.root, name);
|
|
251
|
+
return target;
|
|
252
|
+
}
|
|
253
|
+
branches(): string[] { return listWorlds(this.root).map((w) => w.name).sort(); }
|
|
254
|
+
/** Is this the main branch? */
|
|
255
|
+
isMain(): boolean { return this.name === mainBranch(this.root); }
|
|
256
|
+
/** What this branch's changes are measured from, as `diff` says it: the branch point, origin, or the story. */
|
|
257
|
+
diffBase(): string {
|
|
258
|
+
if (!this.isMain()) return `branch ${this.name}`;
|
|
259
|
+
if (this.remote('origin')) return 'origin';
|
|
260
|
+
return existsSync(worldSeedPath(this.root)) ? 'the story' : 'the default data';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── the clock ───────────────────────────────────────────────────────────────────────────
|
|
264
|
+
/** The world's clock: the frozen instant every twin stamps from, or the wall clock when unset. */
|
|
265
|
+
clock(): { at: string; frozen: boolean } {
|
|
266
|
+
const file = clockFile(this.root, this.name);
|
|
267
|
+
return existsSync(file) ? { at: readFileSync(file, 'utf8').trim(), frozen: true } : { at: new Date().toISOString(), frozen: false };
|
|
268
|
+
}
|
|
269
|
+
/** Set the world's clock to an instant; every twin stamps from it until it moves. */
|
|
270
|
+
setClock(iso: string): string {
|
|
271
|
+
const parsed = Date.parse(iso);
|
|
272
|
+
if (Number.isNaN(parsed)) throw new Error(`${JSON.stringify(iso)} is not an ISO-8601 instant`);
|
|
273
|
+
const file = clockFile(this.root, this.name);
|
|
274
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
275
|
+
writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
|
|
276
|
+
return new Date(parsed).toISOString();
|
|
277
|
+
}
|
|
278
|
+
/** Move a set clock forward: `30d`, `12h`, `5m`, `90s`. */
|
|
279
|
+
advanceClock(by: string): string {
|
|
280
|
+
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
|
|
281
|
+
if (!m) throw new Error(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
|
|
282
|
+
const current = this.clock();
|
|
283
|
+
if (!current.frozen) throw new Error('the clock is not set — `volter world clock set <iso>` first; advancing the wall clock would freeze time as a side effect');
|
|
284
|
+
const ms = Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
|
|
285
|
+
return this.setClock(new Date(Date.parse(current.at) + ms).toISOString());
|
|
286
|
+
}
|
|
287
|
+
/** Rebase this branch onto its base's current position, every twin; conflicts named per subject and field. */
|
|
288
|
+
rebaseBranch(): Array<{ service: string } & ReturnType<typeof rebaseBranch>> { return this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root) })); }
|
|
289
|
+
/** Replay a changeset's changes into another branch's twins, in order, with the same ids. */
|
|
290
|
+
replay(name: string, into: string): ReturnType<typeof replayWorldChangeset> { return replayWorldChangeset(name, { root: this.root, world: this.name, into }); }
|
|
291
|
+
|
|
292
|
+
// ── serving ─────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
/** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
|
|
294
|
+
serve(opts: { port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> { return serveWorld(this.name, { root: this.root, ...opts }); }
|
|
295
|
+
|
|
296
|
+
// ── remotes ─────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
/** The remotes named in world.json (`volter remote add`), name → url or path. */
|
|
298
|
+
remotes(): Record<string, string> { return { ...(loadWorldConfig(this.configRef(), this.root).config.remotes ?? {}) }; }
|
|
299
|
+
/** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
|
|
300
|
+
addRemote(name: string, target: string, opts: { token?: string } = {}): void {
|
|
301
|
+
const { path, config } = loadWorldConfig(this.configRef(), this.root);
|
|
302
|
+
const remotes = { ...(config.remotes ?? {}), [name]: target };
|
|
303
|
+
writeWorldConfig(path, { ...config, remotes });
|
|
304
|
+
if (opts.token) storeToken(parseOriginUrl(target).url, opts.token);
|
|
305
|
+
}
|
|
306
|
+
removeRemote(name: string): void {
|
|
307
|
+
const { path, config } = loadWorldConfig(this.configRef(), this.root);
|
|
308
|
+
const remotes = { ...(config.remotes ?? {}) }; delete remotes[name];
|
|
309
|
+
writeWorldConfig(path, { ...config, remotes });
|
|
310
|
+
}
|
|
311
|
+
/** The remote a verb uses: the named one, else origin from world.json, else the instance's recorded origin. */
|
|
312
|
+
remote(name = 'origin'): { url: string; namespace: string } | null {
|
|
313
|
+
const target = this.remotes()[name];
|
|
314
|
+
if (target) return parseOriginUrl(target);
|
|
315
|
+
if (name !== 'origin') return null;
|
|
316
|
+
const recorded = worldOrigin(this.name, this.root);
|
|
317
|
+
return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── the origin ──────────────────────────────────────────────────────────────────────────
|
|
321
|
+
/** The origin this world clones from and pushes to, or null: the default data is its only origin. */
|
|
322
|
+
origin(): WorldInstance['origin'] | null { const r = this.remote('origin'); const recorded = worldOrigin(this.name, this.root); return r ? { ...(recorded ?? {}), url: r.url, namespace: r.namespace } : recorded; }
|
|
323
|
+
/**
|
|
324
|
+
* Clone a remote's canonical history into this world: record the origin, remember the token,
|
|
325
|
+
* fetch everything. With the world's token (namespace or read), the history arrives through the
|
|
326
|
+
* mirror, from the beginning; with `adminToken` as well, the remote's whole tree is copied in one
|
|
327
|
+
* move, which is what an admin can do and a reader cannot. Either way one token is remembered:
|
|
328
|
+
* the world's, which is what `fetch` and `push` use afterwards.
|
|
329
|
+
*/
|
|
330
|
+
async clone(url: string, opts: { token?: string; adminToken?: string } = {}): Promise<ReturnType<typeof fetchFromOrigin>> {
|
|
331
|
+
const origin = parseOriginUrl(url);
|
|
332
|
+
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
|
|
333
|
+
if (opts.adminToken !== undefined && opts.adminToken !== '') {
|
|
334
|
+
if (opts.token === undefined || opts.token === '') storeToken(origin.url, opts.adminToken);
|
|
335
|
+
return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key: opts.adminToken, full: true });
|
|
336
|
+
}
|
|
337
|
+
const key = requireToken(origin.url, opts.token);
|
|
338
|
+
// v2: a clone brings the served world's whole log in from position zero, and names it origin;
|
|
339
|
+
// a world that has never run is booted first (no story: its history is the origin's)
|
|
340
|
+
this.addRemote('origin', url);
|
|
341
|
+
if (!this.instance()) await this.up({ seed: false });
|
|
342
|
+
return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key, full: true });
|
|
343
|
+
}
|
|
344
|
+
/** Fetch, then move this branch onto what came in: git's pull. */
|
|
345
|
+
async pull(opts: { token?: string; services?: string[] } = {}): Promise<{ fetched: Awaited<ReturnType<typeof fetchFromOrigin>>; rebased: ReturnType<World['rebaseBranch']> }> {
|
|
346
|
+
const fetched = await this.fetch(opts);
|
|
347
|
+
return { fetched, rebased: this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root, { origin: true }) })) };
|
|
348
|
+
}
|
|
349
|
+
/** Fetch what the origin observed since the last fetch. */
|
|
350
|
+
fetch(opts: { token?: string; services?: string[] } = {}): ReturnType<typeof fetchFromOrigin> {
|
|
351
|
+
const origin = this.origin();
|
|
352
|
+
if (!origin) throw new Error(`World "${this.name}" has no origin — its only origin is the default data. \`volter world clone <url>\` connects a remote.`);
|
|
353
|
+
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token); // the last token given for a remote is the one remembered
|
|
354
|
+
return fetchFromOrigin(this.name, { root: this.root, url: origin.url, namespace: origin.namespace, key: requireToken(origin.url, opts.token), ...(opts.services ? { services: opts.services } : {}) });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── changesets and push ─────────────────────────────────────────────────────────────────
|
|
358
|
+
/** Cut a changeset from the unpushed changes: the reviewable unit, with the author's message. */
|
|
359
|
+
changeset(opts: { name?: string; message?: string; base?: string; verifiers?: Parameters<typeof createWorldChangeset>[2] extends infer O ? (O extends { verifiers?: infer V } ? V : never) : never; overwrite?: boolean } = {}): Changeset {
|
|
360
|
+
const name = opts.name ?? (opts.message ? slugName(opts.message, this.changesets()) : nextChangesetName(this.changesets()));
|
|
361
|
+
return createWorldChangeset(this.name, name, { root: this.root, ...(opts.message !== undefined ? { message: opts.message } : {}), ...(opts.base ? { base: opts.base } : {}), ...(opts.verifiers ? { verifiers: opts.verifiers } : {}), ...(opts.overwrite ? { overwrite: true } : {}) });
|
|
362
|
+
}
|
|
363
|
+
changesets(): ChangesetLocation[] { return listWorldChangesets({ root: this.root, world: this.name }); }
|
|
364
|
+
/**
|
|
365
|
+
* Push to the origin, which deploys to the vendor with its own keys and answers with receipts:
|
|
366
|
+
* the named changeset, or every changeset not yet pushed, oldest first. A local world never
|
|
367
|
+
* holds a vendor credential.
|
|
368
|
+
*/
|
|
369
|
+
async push(opts: { name?: string; token?: string; force?: boolean } = {}): Promise<Array<Awaited<ReturnType<typeof pushWorldChangeset>>>> {
|
|
370
|
+
const origin = this.origin();
|
|
371
|
+
if (!origin) throw new Error(`World "${this.name}" has no origin to push to — the default data cannot be pushed to. \`volter world clone <url>\` connects a remote.`);
|
|
372
|
+
if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
|
|
373
|
+
const key = requireToken(origin.url, opts.token);
|
|
374
|
+
const to = { to: origin.url, namespace: origin.namespace };
|
|
375
|
+
const queue = opts.name !== undefined
|
|
376
|
+
? [findWorldChangeset(opts.name, { root: this.root, world: this.name })]
|
|
377
|
+
: this.changesets().filter((c) => c.changeset.applied === null || c.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
378
|
+
if (queue.length === 0) throw new Error(this.unpushed().length ? 'Nothing to push: cut a changeset first — `volter world changeset -m "<what and why>"`' : 'Nothing to push: no unpushed changes');
|
|
379
|
+
const outcomes: Array<Awaited<ReturnType<typeof pushWorldChangeset>>> = [];
|
|
380
|
+
for (const located of queue) {
|
|
381
|
+
const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, ...(opts.force ? { force: true } : {}) });
|
|
382
|
+
outcomes.push(outcome);
|
|
383
|
+
if (outcome.application.outcome === 'refused') break;
|
|
384
|
+
}
|
|
385
|
+
return outcomes;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── review (the operator's verbs, kept on the SDK for the remote and for CI) ────────────
|
|
389
|
+
mark(id?: string, now?: Date): WorldMarker { return markWorld(this.name, { root: this.root, ...(id ? { id } : {}), ...(now ? { now } : {}) }); }
|
|
390
|
+
marks(): WorldMarker[] { return listWorldMarks(this.name, this.root); }
|
|
391
|
+
verify(name: string, opts: { into?: string; ephemeral?: boolean } = { ephemeral: true }): ReturnType<typeof verifyWorldChangeset> { return verifyWorldChangeset(name, { root: this.root, world: this.name, ...opts }); }
|
|
392
|
+
approve(name: string, principal: string, note?: string): ReturnType<typeof approveWorldChangeset> { return approveWorldChangeset(name, { root: this.root, world: this.name, principal, ...(note ? { note } : {}) }); }
|
|
393
|
+
readiness(name: string): ReturnType<typeof statusWorldChangeset> { return statusWorldChangeset(name, { root: this.root, world: this.name }); }
|
|
394
|
+
rebase(name: string): ReturnType<typeof rebaseWorldChangeset> { return rebaseWorldChangeset(name, { root: this.root, world: this.name }); }
|
|
395
|
+
// ── roots (docs/concepts/the-model.md: a twin on a shared world whose root is the vendor) ──
|
|
396
|
+
/** The twin's root and credential, as `volter twin <vendor>` prints them. */
|
|
397
|
+
twin(vendor: string): { vendor: string; url?: string; root: RootConfig | null; credential: { placedAt: string; fingerprint: string } | null } {
|
|
398
|
+
const { config } = loadWorldConfig(this.configRef(), this.root);
|
|
399
|
+
const service = config.services.find((s) => s.id === vendor);
|
|
400
|
+
if (!service) throw new Error(`no twin "${vendor}" in this world`);
|
|
401
|
+
const inst = this.instance();
|
|
402
|
+
return { vendor, ...(inst?.services[vendor]?.url ? { url: inst.services[vendor]!.url } : {}), root: (service as { root?: RootConfig }).root ?? null, credential: sealedCredentialInfo(this.root, vendor) };
|
|
403
|
+
}
|
|
404
|
+
/** Set (or clear) the twin's root: the vendor's API and the deploy policy, in world.json. Takes effect at the next `up` or `serve`, or now when the world runs. */
|
|
405
|
+
setTwinRoot(vendor: string, root: RootConfig | null): void {
|
|
406
|
+
setTwinRoot(this.root, vendor, root, { config: this.configRef() });
|
|
407
|
+
const inst = this.instance();
|
|
408
|
+
if (inst?.running) { materializeRoots(loadWorldConfig(this.configRef(), this.root).config, inst.dirs.data, this.root); }
|
|
409
|
+
}
|
|
410
|
+
/** Seal the vendor's credential beside the world (a bare token, or a JSON payload). Never readable back. */
|
|
411
|
+
sealTwinCredential(vendor: string, input: string): Promise<{ placedAt: string; fingerprint: string }> { return sealTwinCredential(this.root, vendor, credentialPayloadFrom(input, vendor)); }
|
|
412
|
+
/** Refresh one twin from its root now. */
|
|
413
|
+
async refreshTwin(vendor: string, opts: { force?: boolean } = {}): Promise<{ refreshed: boolean; reason?: string; report?: { observed: number; appended: number; unchanged: number; removed: number } }> {
|
|
414
|
+
const { controlRoot, root } = this.rootOf(vendor);
|
|
415
|
+
return refreshTwin({ worldRoot: this.root, vendor, controlRoot, root, ...(opts.force ? { force: true } : {}) });
|
|
416
|
+
}
|
|
417
|
+
private rootOf(vendor: string): { controlRoot: string; root: MaterializedRoot } {
|
|
418
|
+
const inst = statusWorld(this.name, this.root);
|
|
419
|
+
const controlRoot = join(inst.dirs.data, vendor);
|
|
420
|
+
const root = rootForControlRoot(controlRoot, vendor);
|
|
421
|
+
if (!root) throw new Error(`the ${vendor} twin has no root — \`volter twin ${vendor} root <url>\` sets one`);
|
|
422
|
+
return { controlRoot, root };
|
|
423
|
+
}
|
|
424
|
+
/** DEPLOY: perform landed entries against each root twin's vendor, by policy (runtime `deployWorld`). */
|
|
425
|
+
deploy(name?: string): Promise<DeployTwinOutcome[]> { return deployWorld(this.name, { root: this.root, ...(name ? { changeset: name } : {}) }); }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The world's name when none is given: the app directory's basename, made safe. */
|
|
429
|
+
export function worldNameFor(app: string): string {
|
|
430
|
+
const base = app.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? 'world';
|
|
431
|
+
const safe = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+/, '');
|
|
432
|
+
return safe === '' ? 'world' : safe;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** A name from the message (`Triage acme/web#1 as OPS-1` → `triage-acme-web-1-as-ops-1`), suffixed when taken. */
|
|
436
|
+
function slugName(message: string, existing: ChangesetLocation[]): string {
|
|
437
|
+
const base = message.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'changeset';
|
|
438
|
+
const taken = new Set(existing.map((c) => c.changeset.name));
|
|
439
|
+
if (!taken.has(base)) return base;
|
|
440
|
+
for (let n = 2; ; n += 1) { const name = `${base}-${n}`; if (!taken.has(name)) return name; }
|
|
441
|
+
}
|
|
442
|
+
/** `changeset-1`, `changeset-2`, … — the next free name when the author gives none. */
|
|
443
|
+
function nextChangesetName(existing: ChangesetLocation[]): string {
|
|
444
|
+
const taken = new Set(existing.map((c) => c.changeset.name));
|
|
445
|
+
for (let n = existing.length + 1; ; n += 1) { const name = `changeset-${n}`; if (!taken.has(name)) return name; }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export { rebaseChangeset, worldBootMarker };
|