@volter/world-core 3.0.155 → 3.0.157
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/attach.cjs +2 -0
- package/capability-decision.cjs +6 -4
- package/dist/attach.cjs +2 -0
- package/dist/capability-decision.cjs +6 -4
- package/dist/generated/pack-facts.json +204 -4
- package/dist/inject.cjs +150 -86
- package/dist/network-policy.cjs +11 -1
- package/dist/network-policy.d.cts +1 -0
- package/dist/pack-facts.d.cts +3 -0
- package/dist/required-credential-transport.cjs +29 -0
- package/dist/required-credential-transport.d.cts +3 -0
- package/dist/src/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +84 -7
- package/dist/src/derived-core.js +336 -61
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/derived.js +3 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/fork.js +1 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +5 -0
- package/dist/src/head.js +15 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +6 -1
- package/dist/src/live/ingest.js +9 -13
- package/dist/src/live/rtmp.js +7 -5
- package/dist/src/live/segmenter.js +7 -1
- package/dist/src/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- package/dist/src/machines.js +5 -1
- package/dist/src/managed-go-programs.d.ts +55 -0
- package/dist/src/managed-go-programs.js +133 -0
- package/dist/src/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +97 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +69 -14
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +7 -5
- package/dist/src/runtime.js +7 -4
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +73 -6
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +122 -7
- package/dist/src/world-store.js +959 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +204 -4
- package/inject.cjs +150 -86
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +27 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/blob-store.ts +59 -1
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +356 -61
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +9 -1
- package/src/executor.ts +17 -3
- package/src/fork.ts +1 -1
- package/src/git/lfs.ts +40 -9
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +15 -0
- package/src/history.ts +7 -7
- package/src/index.ts +7 -1
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/log.ts +147 -32
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +223 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +66 -16
- package/src/pack-paths.ts +5 -1
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/resource-blob.ts +9 -1
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +10 -3
- package/src/serve-http.ts +65 -6
- package/src/serve.ts +45 -3
- package/src/sockets.ts +39 -8
- package/src/storage.ts +5 -3
- package/src/vendor-call.ts +54 -0
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +768 -91
- package/src/xml.ts +71 -0
package/src/live/ingest.ts
CHANGED
|
@@ -57,7 +57,6 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
|
|
|
57
57
|
let priorInit: number | undefined;
|
|
58
58
|
let started = false;
|
|
59
59
|
let metadata: Record<string, unknown> | undefined;
|
|
60
|
-
let refusedCodec: string | undefined;
|
|
61
60
|
|
|
62
61
|
const recording = (): LiveRecording => {
|
|
63
62
|
const row = store.rows(LIVE).find((r) => r.id === stream)!;
|
|
@@ -135,7 +134,12 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
|
|
|
135
134
|
app: ask!.app, key: ask!.key, state: 'publishing', startedAt: typeof prior?.startedAt === 'string' ? prior.startedAt : now, endedAt: null,
|
|
136
135
|
publishes: Number(prior?.publishes ?? 0) + 1, window: ruling.window ?? 6, segmentSeconds: ruling.segmentSeconds ?? 2, ...(ruling.retain !== undefined ? { retain: ruling.retain } : {}),
|
|
137
136
|
}, 'live.publish'));
|
|
137
|
+
// Every admitted publish has its own encoder and hook state. Continuation comes from the recording above,
|
|
138
|
+
// including on a connection reused after FCUnpublish, never from the prior publish's in-memory state.
|
|
139
|
+
initIndex = -1;
|
|
138
140
|
priorInit = last?.init;
|
|
141
|
+
started = false;
|
|
142
|
+
metadata = undefined;
|
|
139
143
|
segmenter = new Segmenter({ targetMs: Math.max(0.5, ruling.segmentSeconds ?? 2) * 1000, offsetMs, firstSequence: last ? last.seq + 1 : 0 });
|
|
140
144
|
stream = named;
|
|
141
145
|
admitted = ruling;
|
|
@@ -154,7 +158,7 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
|
|
|
154
158
|
async video(timestamp, payload) {
|
|
155
159
|
if (!segmenter) return;
|
|
156
160
|
const tag = readVideoTag(payload);
|
|
157
|
-
if (tag.kind === 'unsupported')
|
|
161
|
+
if (tag.kind === 'unsupported') throw new RtmpRefusal('NetStream.Failed', `${tag.codec} video is not supported; publish H.264.`);
|
|
158
162
|
const s = segmenter;
|
|
159
163
|
if (tag.kind === 'config') await keepAll(s.videoConfig(tag.config));
|
|
160
164
|
else if (tag.kind === 'frame') await keepAll(overrun(() => s.videoFrame(timestamp, tag.key, tag.cts, tag.data)));
|
|
@@ -162,7 +166,7 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
|
|
|
162
166
|
async audio(timestamp, payload) {
|
|
163
167
|
if (!segmenter) return;
|
|
164
168
|
const tag = readAudioTag(payload);
|
|
165
|
-
if (tag.kind === 'unsupported')
|
|
169
|
+
if (tag.kind === 'unsupported') throw new RtmpRefusal('NetStream.Failed', `${tag.codec} audio is not supported; publish AAC.`);
|
|
166
170
|
const s = segmenter;
|
|
167
171
|
if (tag.kind === 'config') await keepAll(s.audioConfig(tag.config));
|
|
168
172
|
else await keepAll(overrun(() => s.audioFrame(timestamp, tag.data)));
|
package/src/live/rtmp.ts
CHANGED
|
@@ -124,6 +124,9 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
|
|
|
124
124
|
chunks.clear();
|
|
125
125
|
current = undefined;
|
|
126
126
|
held = 0;
|
|
127
|
+
// A refused/ended transport owns the publish's cleanup too. An admission already awaiting the dialect settles
|
|
128
|
+
// first; messages queued behind the refusal cannot admit a new publish on the closed connection.
|
|
129
|
+
chain = chain.then(() => stop()).catch((error) => log(`the publish's end failed: ${String((error as Error)?.message ?? error)}`));
|
|
127
130
|
try { sink.end(); } catch { /* the peer is gone */ }
|
|
128
131
|
};
|
|
129
132
|
/** A connection past a ceiling: said once, and closed. */
|
|
@@ -175,6 +178,7 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
|
|
|
175
178
|
try { refusal = await publisher.publish({ app, name: named, ...(type !== undefined ? { type } : {}), ...(tcUrl !== undefined ? { tcUrl } : {}), ...(flashVer !== undefined ? { flashVer } : {}) }); }
|
|
176
179
|
catch (error) { log(`publish failed: ${String((error as Error)?.message ?? error)}`); refusal = { code: 'NetStream.Publish.Denied', description: 'The server could not take the stream.' }; }
|
|
177
180
|
if (refusal) { status(stream, 'error', refusal.code, refusal.description, named); close(); return; }
|
|
181
|
+
ended = false;
|
|
178
182
|
publishing = true;
|
|
179
183
|
publishStream = stream;
|
|
180
184
|
// user control StreamBegin (event 0) for the publish's stream, then its start
|
|
@@ -257,7 +261,7 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
|
|
|
257
261
|
const message: Message = { type: st.type, streamId: st.streamId, timestamp: st.timestamp, payload };
|
|
258
262
|
queued += payload.length;
|
|
259
263
|
if (queued > MAX_QUEUED) { overrun(`more than ${MAX_QUEUED} bytes of messages wait to be recorded`); return; }
|
|
260
|
-
chain = chain.then(() => (phase === 'closed'
|
|
264
|
+
chain = chain.then(() => (phase === 'closed' ? undefined : onMessage(message))).catch((error) => {
|
|
261
265
|
log(`a message failed: ${String((error as Error)?.message ?? error)}`);
|
|
262
266
|
close();
|
|
263
267
|
}).finally(() => { queued -= payload.length; });
|
|
@@ -366,10 +370,8 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
|
|
|
366
370
|
// message's bytes are not in it: only more bytes finish it, and what it may hold is bounded (MAX_HELD)
|
|
367
371
|
backlog: () => queued + pending.length,
|
|
368
372
|
close() {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
chain = chain.then(() => stop()).catch((error) => log(`the publish's end failed: ${String((error as Error)?.message ?? error)}`));
|
|
372
|
-
phase = 'closed';
|
|
373
|
+
// Peer close and server refusal share one end, after any admission or write already under way settles.
|
|
374
|
+
close();
|
|
373
375
|
},
|
|
374
376
|
};
|
|
375
377
|
}
|
package/src/live/segmenter.ts
CHANGED
|
@@ -64,14 +64,20 @@ export class Segmenter {
|
|
|
64
64
|
this.avc = config;
|
|
65
65
|
this.freshInit = true;
|
|
66
66
|
this.keyed = false;
|
|
67
|
+
this.unkeyedSince = undefined;
|
|
67
68
|
return out;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
audioConfig(config: AacConfig): Cut[] {
|
|
71
72
|
if (this.aac && same(this.aac.asc, config.asc)) return [];
|
|
72
|
-
const out = this.
|
|
73
|
+
const out = this.flush();
|
|
73
74
|
this.aac = config;
|
|
74
75
|
this.freshInit = true;
|
|
76
|
+
// A configuration boundary has no running video segment. Its first frame must be a new keyframe, and the
|
|
77
|
+
// audio sample clock belongs to the new configuration (which may have a different sample rate).
|
|
78
|
+
this.keyed = false;
|
|
79
|
+
this.unkeyedSince = undefined;
|
|
80
|
+
this.audioNext = undefined;
|
|
75
81
|
return out;
|
|
76
82
|
}
|
|
77
83
|
|
package/src/log.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { resolveReferences } from './references.ts';
|
|
|
30
30
|
import { hashFieldValue } from './hash.ts';
|
|
31
31
|
import type { SubjectFields } from './hash.ts';
|
|
32
32
|
import { worldPaths } from './storage.ts';
|
|
33
|
-
import { getActiveWorldStore } from './world-store.ts';
|
|
33
|
+
import { getActiveWorldStore, TREE_FILES } from './world-store.ts';
|
|
34
34
|
import type { WorldServiceEvent } from './types.ts';
|
|
35
35
|
import type { TwinResource } from './serve.ts';
|
|
36
36
|
|
|
@@ -97,8 +97,8 @@ const TREE_PROJECTION_VERSION = 2;
|
|
|
97
97
|
export const DELTA_SUFFIX = '.delta';
|
|
98
98
|
|
|
99
99
|
export function parentLogPath(service: string, root?: string): string { return worldPaths(service, root).events; }
|
|
100
|
-
export function branchLogPath(service: string, root?: string): string { return join(dirname(worldPaths(service, root).events),
|
|
101
|
-
export function branchMetaPath(service: string, root?: string): string { return join(dirname(worldPaths(service, root).events),
|
|
100
|
+
export function branchLogPath(service: string, root?: string): string { return join(dirname(worldPaths(service, root).events), TREE_FILES.branch); }
|
|
101
|
+
export function branchMetaPath(service: string, root?: string): string { return join(dirname(worldPaths(service, root).events), TREE_FILES.pointer); }
|
|
102
102
|
/** The fetched cache of a URL parent's whole log — what `fetch` extends and a read folds up to the position. */
|
|
103
103
|
export function originLogPath(service: string, root?: string): string { return join(dirname(worldPaths(service, root).events), 'origin.jsonl'); }
|
|
104
104
|
/** A parent is at a URL when it is not a path on this machine. */
|
|
@@ -173,8 +173,9 @@ function readRows<T>(path: string): T[] {
|
|
|
173
173
|
return rows.slice();
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
export function readBranchMeta(service: string, root?: string): BranchMeta | null {
|
|
177
|
-
|
|
176
|
+
export function readBranchMeta(service: string, root?: string, read?: string | null): BranchMeta | null {
|
|
177
|
+
// `read`: branch.json's text where the caller has just read it (a tree's stamp reads it once for both uses).
|
|
178
|
+
const raw = read !== undefined ? read : getActiveWorldStore().read(branchMetaPath(service, root));
|
|
178
179
|
if (raw === null) return null;
|
|
179
180
|
const meta = parseExactJson(raw) as BranchMeta & { parent?: { root?: string; parentCount?: number; branchCount?: number } };
|
|
180
181
|
// a pointer written before positions were one number: the parent's whole log is parent then branch
|
|
@@ -228,8 +229,8 @@ export function writeBranchMeta(service: string, meta: BranchMeta, root?: string
|
|
|
228
229
|
getActiveWorldStore().mkdir(dirname(path));
|
|
229
230
|
getActiveWorldStore().writeAtomic(path, `${JSON.stringify(meta, null, 2)}\n`);
|
|
230
231
|
if (meta.parent?.directory && !isUrlParent(meta.parent.at)) commitParentPin(meta.parent.directory, path);
|
|
231
|
-
});
|
|
232
|
-
});
|
|
232
|
+
}, { operation: 'pointer publication' });
|
|
233
|
+
}, { operation: 'pointer publication' });
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
/** A v1 observed row as an entry: a delta event folds its `changed.*.after`; anything else folds
|
|
@@ -304,7 +305,7 @@ export function appendOriginEntry(entry: Entry, root?: string): { appended: bool
|
|
|
304
305
|
if (readRows<{ id?: string }>(path).some((r) => r.id === entry.id)) return { appended: false };
|
|
305
306
|
store.append(path, `${JSON.stringify(entry)}\n`);
|
|
306
307
|
return { appended: true };
|
|
307
|
-
}));
|
|
308
|
+
}, { operation: 'origin append' }), { operation: 'origin append' });
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
/** Where the parent stands now: the length of its whole log (live at a path, the cache for a URL). */
|
|
@@ -338,7 +339,7 @@ export function aliasesFrom(parent: Entry[]): Map<string, string> {
|
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
const META = new Set(['id', 'type', 'updatedAt']);
|
|
341
|
-
type Subject = { type: string; id: string; updatedAt: string; fields: SubjectFields };
|
|
342
|
+
export type Subject = { type: string; id: string; updatedAt: string; fields: SubjectFields };
|
|
342
343
|
export type Tree = Map<string, Subject>;
|
|
343
344
|
|
|
344
345
|
/** Last write per field. `deleted: true` is the tombstone a delete carries; it holds until the subject
|
|
@@ -351,6 +352,24 @@ function overlay(tree: Tree, type: string, id: string, fields: SubjectFields, at
|
|
|
351
352
|
tree.set(key, { ...existing, updatedAt: at, fields: merged });
|
|
352
353
|
}
|
|
353
354
|
|
|
355
|
+
/** One subject read by the same guarded key as a write's answer; restore the vendor's own metadata fields. */
|
|
356
|
+
export function heldSubject(service: string, type: string, id: string, root?: string): Subject | undefined {
|
|
357
|
+
const row = readSubject(service, type, id, root);
|
|
358
|
+
return row ? { type: row.type, id: row.id, updatedAt: row.updatedAt, fields: ownFields(row) } : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One local write folded onto its subject as the tree folds its entry (`overlay`): the subject as it was (undefined
|
|
363
|
+
* for one new to the tree) is copied, never changed. A command's writes staged in `ctx.batch` are read back by
|
|
364
|
+
* folding them this way, so a read inside the batch sees what the tree will hold once they land (derived-core.ts).
|
|
365
|
+
*/
|
|
366
|
+
export function foldLocalWrite(previous: Subject | undefined, type: string, id: string, fields: SubjectFields, at: string): Subject {
|
|
367
|
+
const key = `${type}:${id}`;
|
|
368
|
+
const tree: Tree = previous ? new Map([[key, { ...previous, fields: { ...previous.fields } }]]) : new Map();
|
|
369
|
+
overlay(tree, type, id, fields, at);
|
|
370
|
+
return tree.get(key)!;
|
|
371
|
+
}
|
|
372
|
+
|
|
354
373
|
/** Fold entries onto a tree, in order: `set` overlays its fields and applies its projection; a
|
|
355
374
|
* reverted entry folds nothing; a `confirm` row (v1) folds nothing. `skip` names entries the
|
|
356
375
|
* parent already holds. */
|
|
@@ -447,6 +466,22 @@ export function foldHistory(entries: Entry[], layout: HistoryLayout, service?: s
|
|
|
447
466
|
foldAliases(tree, aliasesFrom(parent), service);
|
|
448
467
|
return tree;
|
|
449
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* One subject of the current tree as a resource, by its type and id: the resource `projectResources(...).find` gives
|
|
471
|
+
* for the same type and id, read by the subject's key without cloning or projecting the rest of the tree. Every subject is
|
|
472
|
+
* held under `${type}:${id}` (overlay is the only setter, and an alias re-keys through it), so the key is the
|
|
473
|
+
* subject. A write's answer read back the whole tree this way (serve.ts, applyTwinWrite's `resource`), once per
|
|
474
|
+
* written subject: a derived pack writing each document, index and collection of a database's startup paid a
|
|
475
|
+
* projection of every subject the World holds for each (S2 R17).
|
|
476
|
+
*/
|
|
477
|
+
export function readSubject(service: string, type: string, id: string, root?: string): TwinResource | undefined {
|
|
478
|
+
const key = `${type}:${id}`;
|
|
479
|
+
const held = currentTree(service, root).tree.get(key);
|
|
480
|
+
// a type or id holding ':' can share a key with another (type, id): only the subject itself is it
|
|
481
|
+
if (!held || held.type !== type || held.id !== id) return undefined;
|
|
482
|
+
return treeResources(new Map([[key, { ...held, fields: { ...held.fields } }]]))[0];
|
|
483
|
+
}
|
|
484
|
+
|
|
450
485
|
export function readTree(service: string, root?: string, opts: { until?: string; at?: number; view?: string } = {}): TwinResource[] {
|
|
451
486
|
return treeResources(readTreeMap(service, root, opts));
|
|
452
487
|
}
|
|
@@ -460,7 +495,7 @@ export function readParentTreeMap(service: string, root?: string): Tree {
|
|
|
460
495
|
return foldHistory([...(inherited ? historyEntries(inherited) : []), ...own], {
|
|
461
496
|
...(inherited ? { base: inherited.layout } : {}), parent: own.length, branch: 0,
|
|
462
497
|
}, service);
|
|
463
|
-
});
|
|
498
|
+
}, { operation: 'tree fold' });
|
|
464
499
|
}
|
|
465
500
|
|
|
466
501
|
// A READ'S TREE, MEMOIZED per store and per process or isolate. What a read folds is named by cheap
|
|
@@ -483,11 +518,44 @@ const TREE_STEPS_KEPT = 64;
|
|
|
483
518
|
function statKey(path: string): string { const s = getActiveWorldStore().stat(path); return s ? `${s.size}:${s.mtimeMs}` : '-'; }
|
|
484
519
|
/** What a service's PARENT side at `root` is folded from (its parent log, branch.json and inherited history), as one
|
|
485
520
|
* string: while it stands, its aliases and landed ids are the same, whatever the branch's own log does. */
|
|
486
|
-
export function parentStamp(service: string, root?: string): string { return
|
|
487
|
-
function parentKeyOf(service: string, root?: string): string { return
|
|
521
|
+
export function parentStamp(service: string, root?: string): string { return treeFacts(service, root).parentKey; }
|
|
522
|
+
function parentKeyOf(service: string, root?: string): string { return parentFacts(service, root).key; }
|
|
523
|
+
/** The parent side's key, and whether it names a file outside the tree's own directory (an inherited view). */
|
|
524
|
+
function parentFacts(service: string, root?: string): { key: string; inherits: boolean } {
|
|
525
|
+
// branch.json is small and every stamp needs it: kept while its own facts stand (world-store.ts readKept), so an
|
|
526
|
+
// unchanged pointer costs one check. Its text is still in the key, so a changed pointer still moves the stamp.
|
|
527
|
+
const store = getActiveWorldStore(), pointer = branchMetaPath(service, root);
|
|
528
|
+
const meta = store.readKept ? store.readKept(pointer) : store.read(pointer);
|
|
529
|
+
const inherited = inheritedHistoryKey(service, root, meta);
|
|
530
|
+
return { key: `${TREE_PROJECTION_VERSION}|${statKey(parentLogPath(service, root))}|${meta ?? ''}|${inherited}`, inherits: inherited !== '' };
|
|
531
|
+
}
|
|
532
|
+
// ONE QUESTION PER STAMP, WHERE THE STORE KEEPS A GENERATION (world-store.ts FsWorldStore.generation, which states
|
|
533
|
+
// what makes it sound and where that stops). A stamp was three questions of the file system, on every operation of
|
|
534
|
+
// every twin: the parent log, the pointer and the branch log, the first two of files a World with no parent does not
|
|
535
|
+
// have. The facts are kept under the generation read BEFORE them, and served while the generation still reads the
|
|
536
|
+
// same. Never kept: where the directory has no generation yet (nothing has been written there by a store that keeps
|
|
537
|
+
// one), and where the parent side names files of other directories (an inherited view: its ancestors' writes move no
|
|
538
|
+
// generation here). Both are asked in full, as before.
|
|
539
|
+
type TreeFacts = { parentKey: string; branchKey: string };
|
|
540
|
+
const keptFacts = new WeakMap<object, Map<string, TreeFacts & { generation: string }>>();
|
|
541
|
+
function treeFacts(service: string, root?: string): TreeFacts {
|
|
542
|
+
const store = getActiveWorldStore();
|
|
543
|
+
const branchLog = branchLogPath(service, root);
|
|
544
|
+
if (!store.generation) return { parentKey: parentKeyOf(service, root), branchKey: statKey(branchLog) };
|
|
545
|
+
const directory = dirname(branchLog);
|
|
546
|
+
const generation = store.generation(directory);
|
|
547
|
+
let kept = keptFacts.get(store);
|
|
548
|
+
if (!kept) { kept = new Map(); keptFacts.set(store, kept); }
|
|
549
|
+
const held = kept.get(directory);
|
|
550
|
+
if (generation !== null && held?.generation === generation) return held;
|
|
551
|
+
const parent = parentFacts(service, root);
|
|
552
|
+
const facts = { parentKey: parent.key, branchKey: statKey(branchLog) };
|
|
553
|
+
if (generation !== null && !parent.inherits) kept.set(directory, { ...facts, generation }); else kept.delete(directory);
|
|
554
|
+
return facts;
|
|
555
|
+
}
|
|
488
556
|
/** What a service's tree (and every subject's history) at `root` is folded from, as one string: while it
|
|
489
557
|
* stands, nothing was written. A pack that projects the tree into its own state memoizes on it. */
|
|
490
|
-
export function treeStamp(service: string, root?: string): string {
|
|
558
|
+
export function treeStamp(service: string, root?: string): string { const facts = treeFacts(service, root); return `${facts.parentKey}#${facts.branchKey}`; }
|
|
491
559
|
const cloneTree = (tree: Tree): Tree => new Map([...tree].map(([k, s]) => [k, { ...s, fields: { ...s.fields } }]));
|
|
492
560
|
const memoStamp = (m: TreeMemo): string => `${m.parentKey}#${m.branchKey}`;
|
|
493
561
|
|
|
@@ -529,7 +597,7 @@ function currentTree(service: string, root?: string): { tree: Tree; memo?: TreeM
|
|
|
529
597
|
// A hit needs no lock: a memo is only ever stored from a fold made under the history lock, and while the
|
|
530
598
|
// facts still match that fold, nothing was written since. A writer mid-batch has moved them.
|
|
531
599
|
const unlocked = memos.get(slot);
|
|
532
|
-
if (unlocked
|
|
600
|
+
if (unlocked) { const facts = treeFacts(service, root); if (unlocked.parentKey === facts.parentKey && unlocked.branchKey === facts.branchKey) return { tree: unlocked.tree, memo: unlocked }; }
|
|
533
601
|
return withHistoryLock(worldPaths(service, root).dir, () => {
|
|
534
602
|
const parentKey = parentKeyOf(service, root); const branchKey = statKey(branchLogPath(service, root));
|
|
535
603
|
const held = memos.get(slot);
|
|
@@ -539,19 +607,7 @@ function currentTree(service: string, root?: string): { tree: Tree; memo?: TreeM
|
|
|
539
607
|
if (held && held.parentKey === parentKey && branch.length >= held.branchCount
|
|
540
608
|
&& (held.branchCount === 0 ? held.lastBranchId === null : branch[held.branchCount - 1]?.id === held.lastBranchId)
|
|
541
609
|
&& !branch.slice(held.branchCount).some((e) => e.op === 'revert')) {
|
|
542
|
-
|
|
543
|
-
const keys = touchedKeys(fresh, held.aliases);
|
|
544
|
-
const from = memoStamp(held);
|
|
545
|
-
let appended: string[];
|
|
546
|
-
try {
|
|
547
|
-
appended = insertionsDuring(held.tree, () => {
|
|
548
|
-
foldEntries(held.tree, fresh, held.landed);
|
|
549
|
-
foldAliases(held.tree, held.aliases, service, keys);
|
|
550
|
-
});
|
|
551
|
-
} catch (error) { memos.delete(slot); throw error; } // a half-folded memo is never read again
|
|
552
|
-
Object.assign(held, { branchKey, branchCount: branch.length, lastBranchId: last(branch) });
|
|
553
|
-
held.steps.push({ from, to: memoStamp(held), keys, appended });
|
|
554
|
-
if (held.steps.length > TREE_STEPS_KEPT) held.steps.splice(0, held.steps.length - TREE_STEPS_KEPT);
|
|
610
|
+
extendMemo(memos, slot, held, branch.slice(held.branchCount), branchKey, service);
|
|
555
611
|
return { tree: held.tree, memo: held };
|
|
556
612
|
}
|
|
557
613
|
const tree = readTreeMapFull(service, root);
|
|
@@ -563,7 +619,66 @@ function currentTree(service: string, root?: string): { tree: Tree; memo?: TreeM
|
|
|
563
619
|
return { tree, memo };
|
|
564
620
|
}
|
|
565
621
|
return { tree };
|
|
566
|
-
});
|
|
622
|
+
}, { operation: 'tree fold' });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The memo extended in place by `fresh`, the branch log's entries after its own, which leave the log at `branchKey`,
|
|
626
|
+
* with the step that records the subjects they touched. */
|
|
627
|
+
function extendMemo(memos: Map<string, TreeMemo>, slot: string, held: TreeMemo, fresh: Entry[], branchKey: string, service: string): void {
|
|
628
|
+
const keys = touchedKeys(fresh, held.aliases);
|
|
629
|
+
const from = memoStamp(held);
|
|
630
|
+
let appended: string[];
|
|
631
|
+
try {
|
|
632
|
+
appended = insertionsDuring(held.tree, () => {
|
|
633
|
+
foldEntries(held.tree, fresh, held.landed);
|
|
634
|
+
foldAliases(held.tree, held.aliases, service, keys);
|
|
635
|
+
});
|
|
636
|
+
} catch (error) { memos.delete(slot); throw error; } // a half-folded memo is never read again
|
|
637
|
+
Object.assign(held, { branchKey, branchCount: held.branchCount + fresh.length, lastBranchId: fresh.length ? fresh[fresh.length - 1]!.id : held.lastBranchId });
|
|
638
|
+
held.steps.push({ from, to: memoStamp(held), keys, appended });
|
|
639
|
+
if (held.steps.length > TREE_STEPS_KEPT) held.steps.splice(0, held.steps.length - TREE_STEPS_KEPT);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// AN OWN APPEND EXTENDS THE READS' MEMO (S2 R17c). A write appended its entries and the next read found the facts
|
|
643
|
+
// moved, so it took the history lock, read the facts again and the log's new bytes, and extended the memo with the
|
|
644
|
+
// entries this process had just written: in a tab, where each of those is a call into the kernel, about twenty calls
|
|
645
|
+
// per command that writes, taken again by the command after it. The writer, still holding the history lock, extends
|
|
646
|
+
// the memo with its own entries and keeps the tree's facts under the generation its own write left, so the next read
|
|
647
|
+
// asks one question. It does so only where the store can say its write was the one write since the generation it
|
|
648
|
+
// read just before (`generationAfterOwnWrite`), the facts were kept under that generation and the memo stood at them,
|
|
649
|
+
// the parent side's facts read after the append are still the kept ones, and the log grew by exactly its bytes;
|
|
650
|
+
// otherwise it extends nothing, and the next read checks as before.
|
|
651
|
+
/** Append `data` (whole JSONL lines of a service's branch log, `append` writing them) and, when the reads' memo held
|
|
652
|
+
* the tree as it stood, extend it by them. Called holding the directory's history lock. Answers the log's facts
|
|
653
|
+
* after the append (`size:mtimeMs`) when it read them, for a caller keyed on the same. */
|
|
654
|
+
export function appendToBranchLog(service: string, root: string | undefined, data: string, append: () => void): string | undefined {
|
|
655
|
+
const store = getActiveWorldStore();
|
|
656
|
+
const memos = treeMemos.get(store);
|
|
657
|
+
const slot = `${service}\u0000${root ?? ''}`;
|
|
658
|
+
const held = memos?.get(slot);
|
|
659
|
+
const branchLog = branchLogPath(service, root);
|
|
660
|
+
const directory = dirname(branchLog);
|
|
661
|
+
const kept = keptFacts.get(store)?.get(directory);
|
|
662
|
+
const standing = held !== undefined && kept !== undefined && kept.parentKey === held.parentKey && kept.branchKey === held.branchKey;
|
|
663
|
+
const before = standing && store.generation && store.generationAfterOwnWrite ? store.generation(directory) : null;
|
|
664
|
+
append();
|
|
665
|
+
if (!memos || !held || !kept || before === null || kept.generation !== before) return undefined;
|
|
666
|
+
const after = store.generationAfterOwnWrite!(directory, before);
|
|
667
|
+
if (after === null) return undefined;
|
|
668
|
+
// read after the generation, as treeFacts reads its facts. The parent side is read again too: a writer the generation
|
|
669
|
+
// does not see (an older version's, or one that died before its second byte) may have moved it under the kept
|
|
670
|
+
// generation, and carried forward its old key would outlive every append this process makes (S2 R17c review).
|
|
671
|
+
const branchKey = statKey(branchLog);
|
|
672
|
+
if (parentKeyOf(service, root) !== kept.parentKey) return branchKey;
|
|
673
|
+
const grown = (key: string): number => (key === '-' ? 0 : Number(key.slice(0, key.indexOf(':'))));
|
|
674
|
+
const fresh: Entry[] = [];
|
|
675
|
+
// line numbers in a refusal count from this append's first line
|
|
676
|
+
parseLines(`${branchLog} (appended)`, data.split('\n'), 0, fresh);
|
|
677
|
+
if (grown(branchKey) !== grown(held.branchKey) + Buffer.byteLength(data) || fresh.some((e) => e.op === 'revert')) return branchKey;
|
|
678
|
+
// the write has landed: a fold that fails here drops the memo (extendMemo) and the next read folds in full, never failing the write
|
|
679
|
+
try { extendMemo(memos, slot, held, fresh, branchKey, service); } catch { return branchKey; }
|
|
680
|
+
keptFacts.get(store)!.set(directory, { parentKey: kept.parentKey, branchKey, generation: after });
|
|
681
|
+
return branchKey;
|
|
567
682
|
}
|
|
568
683
|
|
|
569
684
|
/** What changed in a service's tree since `since` (a `treeStamp` an earlier read was made at): the subjects
|
|
@@ -649,7 +764,7 @@ function readTreeMapFull(service: string, root?: string, opts: { until?: string;
|
|
|
649
764
|
getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest, branchDigest, branchCount: branch.length, subjects: [...tree.values()], ...(now === facts ? { facts } : {}) }));
|
|
650
765
|
}
|
|
651
766
|
return tree;
|
|
652
|
-
});
|
|
767
|
+
}, { operation: 'tree fold' });
|
|
653
768
|
}
|
|
654
769
|
export function cutCheckpoint(service: string, root?: string): void {
|
|
655
770
|
return withHistoryLock(worldPaths(service, root).dir, () => {
|
|
@@ -657,7 +772,7 @@ export function cutCheckpoint(service: string, root?: string): void {
|
|
|
657
772
|
const subjects = [...readTreeMap(service, root).values()];
|
|
658
773
|
const inherited = inheritedHistory(service, root); const own = ownParentEntries(service, root);
|
|
659
774
|
getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ projectionVersion: TREE_PROJECTION_VERSION, inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
|
|
660
|
-
});
|
|
775
|
+
}, { operation: 'checkpoint' });
|
|
661
776
|
}
|
|
662
777
|
|
|
663
778
|
/** The branch's entries the parent does not hold: `set` rows, not reverted, not landed. */
|
|
@@ -744,7 +859,7 @@ export function appendParentEntry(entry: Entry, root?: string): { appended: bool
|
|
|
744
859
|
index.ids.add(entry.id);
|
|
745
860
|
index.key = statKey(path);
|
|
746
861
|
return { appended: true };
|
|
747
|
-
}));
|
|
862
|
+
}, { operation: 'parent append' }), { operation: 'parent append' });
|
|
748
863
|
}
|
|
749
864
|
|
|
750
865
|
/** The whole log of a twin as a served world hands it out: the parent view, then the branch's own
|
|
@@ -815,7 +930,7 @@ export function rebaseBranch(service: string, root?: string, opts: { origin?: bo
|
|
|
815
930
|
dropCheckpoint(service, root);
|
|
816
931
|
}
|
|
817
932
|
return { moved, from, to, conflicts };
|
|
818
|
-
});
|
|
933
|
+
}, { operation: 'branch-pointer adjustment' });
|
|
819
934
|
}
|
|
820
935
|
|
|
821
936
|
/**
|
package/src/machines.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// is reached only when a compute pool is asked to act. The light pool delegates to the host's Substrate adapter.
|
|
7
7
|
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
8
8
|
import { nodeBuiltin } from './serve-http.ts';
|
|
9
|
+
import { refuseRequiredMachineTransport } from '../required-credential-transport.cjs';
|
|
9
10
|
|
|
10
11
|
export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[]; checks?: Array<{ port: number; type: 'http' | 'tcp'; path?: string }>; resetOnStop?: boolean; mounts?: Array<{ name: string; path: string }>; signal?: AbortSignal; startTimeoutMs?: number };
|
|
11
12
|
export type GuestImage = { context: string; dockerfile?: string; runtime?: 'node' | 'bun' | 'python' | 'deno'; command?: string[]; cwd?: string; env?: Record<string, string>; prepared?: boolean };
|
|
@@ -94,6 +95,7 @@ function dockerPool(world: string): MachinePool {
|
|
|
94
95
|
});
|
|
95
96
|
return {
|
|
96
97
|
kind: 'docker',
|
|
98
|
+
async admitRun() { refuseRequiredMachineTransport(process.env, 'Docker machine application transport'); },
|
|
97
99
|
async status(name) {
|
|
98
100
|
const state = JSON.parse(await docker(['inspect', '--format', '{{json .State}}', containerName(world, name)]));
|
|
99
101
|
return state.Running ? { state: 'started' } : { state: 'exited', exitCode: state.ExitCode, ...(state.Error ? { reason: state.Error } : {}) };
|
|
@@ -101,6 +103,7 @@ function dockerPool(world: string): MachinePool {
|
|
|
101
103
|
async ports() { throw new Error('port discovery requires the light guest backing'); },
|
|
102
104
|
async files() { throw new Error('file access requires the light guest backing'); },
|
|
103
105
|
async run(spec) {
|
|
106
|
+
refuseRequiredMachineTransport(process.env, 'Docker machine application transport');
|
|
104
107
|
const name = containerName(world, spec.name);
|
|
105
108
|
await docker(['rm', '-f', name]).catch(() => undefined);
|
|
106
109
|
const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
@@ -114,6 +117,7 @@ function dockerPool(world: string): MachinePool {
|
|
|
114
117
|
return { ports: published };
|
|
115
118
|
},
|
|
116
119
|
async exec(name, spec) {
|
|
120
|
+
refuseRequiredMachineTransport(process.env, 'Docker machine exec transport');
|
|
117
121
|
const { spawn } = nodeBuiltin<typeof import('node:child_process')>('node:child_process');
|
|
118
122
|
const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
119
123
|
// the shell names its own pid first, then becomes the process: its pid is the process's
|
|
@@ -176,7 +180,7 @@ function dockerPool(world: string): MachinePool {
|
|
|
176
180
|
return fetch(`http://127.0.0.1:${host}${source.pathname}${source.search}`, { method: request.method, headers, body, redirect: 'manual', signal: request.signal });
|
|
177
181
|
},
|
|
178
182
|
async pause(name) { await docker(['pause', containerName(world, name)]); },
|
|
179
|
-
async resume(name) { await docker(['unpause', containerName(world, name)]); },
|
|
183
|
+
async resume(name) { refuseRequiredMachineTransport(process.env, 'Docker machine resume transport'); await docker(['unpause', containerName(world, name)]); },
|
|
180
184
|
async stop(name) { await docker(['stop', containerName(world, name)]).catch(() => undefined); },
|
|
181
185
|
async remove(name) { await docker(['rm', '-f', containerName(world, name)]).catch(() => undefined); },
|
|
182
186
|
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Tagged managed Go operation metadata. This structural reader grants no
|
|
2
|
+
* source/image correspondence, filesystem custody, catalog admission or exec. */
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface NativeGoKitReference {
|
|
6
|
+
package: { name: string; version: string };
|
|
7
|
+
target: 'wasm32-wali-go-single-thread';
|
|
8
|
+
releaseSha256: string;
|
|
9
|
+
producerSha256: string;
|
|
10
|
+
sourceSha256: string;
|
|
11
|
+
selection: { path: string; sha256: string };
|
|
12
|
+
recipe: { path: string; sha256: string };
|
|
13
|
+
installedRoot: string;
|
|
14
|
+
}
|
|
15
|
+
export interface NativeGoOperation {
|
|
16
|
+
sourceService: string;
|
|
17
|
+
role: 'server' | 'prerequisite' | 'health';
|
|
18
|
+
kit: string;
|
|
19
|
+
program: { name: string; version: string; manifestId: string; manifestSha256: string; path: string };
|
|
20
|
+
/** Resolved arguments require separate comparison with the full source. */
|
|
21
|
+
args: string[];
|
|
22
|
+
/** Names of existing World values, never caller secrets in this record. */
|
|
23
|
+
environmentBindings: Record<string, string>;
|
|
24
|
+
requires: { service: string; condition: 'service_started' | 'service_healthy' | 'service_completed_successfully' }[];
|
|
25
|
+
}
|
|
26
|
+
export interface NativeGoReference {
|
|
27
|
+
version: 2;
|
|
28
|
+
implementation: string;
|
|
29
|
+
release: string;
|
|
30
|
+
directory: string;
|
|
31
|
+
snapshotSha256: string;
|
|
32
|
+
declarationSha256: string;
|
|
33
|
+
image: string;
|
|
34
|
+
kits: Record<string, NativeGoKitReference>;
|
|
35
|
+
server: NativeGoOperation;
|
|
36
|
+
prerequisites: NativeGoOperation[];
|
|
37
|
+
health: NativeGoOperation;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fail = (field: string): never => { throw new Error('Native Go operation metadata is invalid: ' + field); };
|
|
41
|
+
function object(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(label);
|
|
43
|
+
const row = value as Record<string, unknown>;
|
|
44
|
+
if (Object.keys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) return fail(label);
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
47
|
+
function text(value: unknown, label: string): string {
|
|
48
|
+
if (typeof value !== 'string' || !value || value.includes('\0')) return fail(label);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function digest(value: unknown, label: string): string {
|
|
52
|
+
const out = text(value, label);
|
|
53
|
+
if (!/^[a-f0-9]{64}$/u.test(out)) return fail(label);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function relativePath(value: unknown, label: string): string {
|
|
57
|
+
const out = text(value, label);
|
|
58
|
+
if (out.startsWith('/') || out.includes('\\') || posix.normalize(out) !== out
|
|
59
|
+
|| out.split('/').some(part => !part || part === '.' || part === '..')) return fail(label);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function installedPath(value: unknown, label: string): string {
|
|
63
|
+
const out = text(value, label);
|
|
64
|
+
if (!out.startsWith('/opt/programs/') || out.includes('\\') || posix.normalize(out) !== out
|
|
65
|
+
|| out.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return fail(label);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function pin(value: unknown, label: string): { path: string; sha256: string } {
|
|
69
|
+
const row = object(value, ['path', 'sha256'], label);
|
|
70
|
+
return { path: relativePath(row.path, label + '.path'), sha256: digest(row.sha256, label + '.sha256') };
|
|
71
|
+
}
|
|
72
|
+
function kit(value: unknown, label: string): NativeGoKitReference {
|
|
73
|
+
const row = object(value, ['package', 'target', 'releaseSha256', 'producerSha256', 'sourceSha256', 'selection', 'recipe', 'installedRoot'], label);
|
|
74
|
+
const pack = object(row.package, ['name', 'version'], label + '.package');
|
|
75
|
+
if (row.target !== 'wasm32-wali-go-single-thread') return fail(label + '.target');
|
|
76
|
+
return { package: { name: text(pack.name, label + '.package.name'), version: text(pack.version, label + '.package.version') },
|
|
77
|
+
target: row.target, releaseSha256: digest(row.releaseSha256, label + '.releaseSha256'),
|
|
78
|
+
producerSha256: digest(row.producerSha256, label + '.producerSha256'), sourceSha256: digest(row.sourceSha256, label + '.sourceSha256'),
|
|
79
|
+
selection: pin(row.selection, label + '.selection'), recipe: pin(row.recipe, label + '.recipe'),
|
|
80
|
+
installedRoot: installedPath(row.installedRoot, label + '.installedRoot') };
|
|
81
|
+
}
|
|
82
|
+
function operation(value: unknown, role: NativeGoOperation['role'], kits: Record<string, NativeGoKitReference>, label: string): NativeGoOperation {
|
|
83
|
+
const row = object(value, ['sourceService', 'role', 'kit', 'program', 'args', 'environmentBindings', 'requires'], label);
|
|
84
|
+
const selected = text(row.kit, label + '.kit');
|
|
85
|
+
if (row.role !== role || !Object.hasOwn(kits, selected)) return fail(label + '.role/kit');
|
|
86
|
+
const provider = kits[selected]!;
|
|
87
|
+
const command = object(row.program, ['name', 'version', 'manifestId', 'manifestSha256', 'path'], label + '.program');
|
|
88
|
+
const path = installedPath(command.path, label + '.program.path'), version = text(command.version, label + '.program.version');
|
|
89
|
+
if (version !== provider.package.version || !path.startsWith(provider.installedRoot + '/')) return fail(label + '.program placement/version');
|
|
90
|
+
if (!Array.isArray(row.args)) return fail(label + '.args');
|
|
91
|
+
const args: string[] = [];
|
|
92
|
+
for (const arg of row.args) {
|
|
93
|
+
if (typeof arg !== 'string' || arg.includes('\0')) return fail(label + '.args.' + args.length);
|
|
94
|
+
args.push(arg);
|
|
95
|
+
}
|
|
96
|
+
if (!row.environmentBindings || typeof row.environmentBindings !== 'object' || Array.isArray(row.environmentBindings)) return fail(label + '.environmentBindings');
|
|
97
|
+
const bindings: Record<string, string> = Object.create(null);
|
|
98
|
+
for (const [name, value] of Object.entries(row.environmentBindings)) {
|
|
99
|
+
const source = text(value, label + '.environmentBindings.' + name);
|
|
100
|
+
if (![name, source].every(item => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(item))) return fail(label + '.environmentBindings name');
|
|
101
|
+
bindings[name] = source;
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(row.requires)) return fail(label + '.requires');
|
|
104
|
+
const dependencies: NativeGoOperation['requires'] = [];
|
|
105
|
+
for (const value of row.requires) {
|
|
106
|
+
const edge = object(value, ['service', 'condition'], label + '.requires');
|
|
107
|
+
if (edge.condition !== 'service_started' && edge.condition !== 'service_healthy' && edge.condition !== 'service_completed_successfully')
|
|
108
|
+
return fail(label + '.requires.condition');
|
|
109
|
+
const service = text(edge.service, label + '.requires.service');
|
|
110
|
+
if (dependencies.some(prior => prior.service === service)) return fail(label + '.requires duplicate service');
|
|
111
|
+
dependencies.push({ service, condition: edge.condition });
|
|
112
|
+
}
|
|
113
|
+
return { sourceService: text(row.sourceService, label + '.sourceService'), role, kit: selected,
|
|
114
|
+
program: { name: text(command.name, label + '.program.name'), version, manifestId: text(command.manifestId, label + '.program.manifestId'),
|
|
115
|
+
manifestSha256: digest(command.manifestSha256, label + '.program.manifestSha256'), path },
|
|
116
|
+
args, environmentBindings: bindings, requires: dependencies };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** All fields are declared data. The later owned consumer must authenticate
|
|
120
|
+
* the complete source, kit, original image and admitted installed manifests. */
|
|
121
|
+
export function nativeGoReference(value: unknown): NativeGoReference {
|
|
122
|
+
const row = object(value, ['version', 'implementation', 'release', 'directory', 'snapshotSha256', 'declarationSha256', 'image', 'kits', 'server', 'prerequisites', 'health'], 'reference');
|
|
123
|
+
if (row.version !== 2) return fail('version');
|
|
124
|
+
const image = text(row.image, 'image');
|
|
125
|
+
if (!/^[^\s@]+@sha256:[a-f0-9]{64}$/u.test(image)) return fail('image');
|
|
126
|
+
if (!row.kits || typeof row.kits !== 'object' || Array.isArray(row.kits) || !Object.keys(row.kits).length) return fail('kits');
|
|
127
|
+
const kits: Record<string, NativeGoKitReference> = Object.create(null);
|
|
128
|
+
for (const [name, value] of Object.entries(row.kits)) kits[text(name, 'kit name')] = kit(value, 'kits.' + name);
|
|
129
|
+
const server = operation(row.server, 'server', kits, 'server'), health = operation(row.health, 'health', kits, 'health');
|
|
130
|
+
const implementation = text(row.implementation, 'implementation'), release = text(row.release, 'release');
|
|
131
|
+
if (kits[server.kit]!.package.name !== implementation || kits[server.kit]!.package.version !== release
|
|
132
|
+
|| health.sourceService !== server.sourceService) return fail('server/health implementation');
|
|
133
|
+
if (!Array.isArray(row.prerequisites)) return fail('prerequisites');
|
|
134
|
+
const prerequisites: NativeGoOperation[] = [];
|
|
135
|
+
for (const value of row.prerequisites)
|
|
136
|
+
prerequisites.push(operation(value, 'prerequisite', kits, 'prerequisites.' + prerequisites.length));
|
|
137
|
+
if (new Set(prerequisites.map(item => item.sourceService)).size !== prerequisites.length
|
|
138
|
+
|| prerequisites.some(item => item.sourceService === server.sourceService)) return fail('prerequisite service');
|
|
139
|
+
const usedKits = new Set([server.kit, health.kit, ...prerequisites.map(item => item.kit)]);
|
|
140
|
+
if (Object.keys(kits).some(name => !usedKits.has(name))) return fail('unconsumed kit');
|
|
141
|
+
for (const item of prerequisites) if (!server.requires.some(edge => edge.service === item.sourceService && edge.condition === 'service_completed_successfully'))
|
|
142
|
+
return fail('server prerequisite completion edge');
|
|
143
|
+
return { version: 2, implementation, release, directory: relativePath(row.directory, 'directory'),
|
|
144
|
+
snapshotSha256: digest(row.snapshotSha256, 'snapshotSha256'), declarationSha256: digest(row.declarationSha256, 'declarationSha256'),
|
|
145
|
+
image, kits, server, prerequisites, health };
|
|
146
|
+
}
|