@hunterzhu/pulse-runtime 0.1.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/dist/context/builder.d.ts +68 -0
- package/dist/context/builder.js +127 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/merger.d.ts +25 -0
- package/dist/context/merger.js +125 -0
- package/dist/core/actions.d.ts +1 -0
- package/dist/core/actions.js +1 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +36 -0
- package/dist/core/events.d.ts +10 -0
- package/dist/core/events.js +24 -0
- package/dist/core/factory.d.ts +35 -0
- package/dist/core/factory.js +27 -0
- package/dist/core/inbox.d.ts +119 -0
- package/dist/core/inbox.js +217 -0
- package/dist/core/mutations.d.ts +80 -0
- package/dist/core/mutations.js +127 -0
- package/dist/core/records.d.ts +1 -0
- package/dist/core/records.js +1 -0
- package/dist/core/types.d.ts +615 -0
- package/dist/core/types.js +109 -0
- package/dist/dependencies/graph.d.ts +25 -0
- package/dist/dependencies/graph.js +92 -0
- package/dist/dependencies/index.d.ts +1 -0
- package/dist/dependencies/index.js +1 -0
- package/dist/dsl/context-proxy.d.ts +20 -0
- package/dist/dsl/context-proxy.js +64 -0
- package/dist/dsl/index.d.ts +4 -0
- package/dist/dsl/index.js +4 -0
- package/dist/dsl/program.d.ts +314 -0
- package/dist/dsl/program.js +756 -0
- package/dist/dsl/session.d.ts +45 -0
- package/dist/dsl/session.js +93 -0
- package/dist/dsl/templates-index.d.ts +1 -0
- package/dist/dsl/templates-index.js +1 -0
- package/dist/dsl/templates.d.ts +85 -0
- package/dist/dsl/templates.js +110 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/scopes.d.ts +38 -0
- package/dist/lifecycle/scopes.js +50 -0
- package/dist/lifecycle/watchdog.d.ts +16 -0
- package/dist/lifecycle/watchdog.js +66 -0
- package/dist/models/actions.d.ts +10 -0
- package/dist/models/actions.js +68 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.js +2 -0
- package/dist/models/router.d.ts +187 -0
- package/dist/models/router.js +353 -0
- package/dist/scheduler/clock.d.ts +45 -0
- package/dist/scheduler/clock.js +92 -0
- package/dist/scheduler/decision.d.ts +72 -0
- package/dist/scheduler/decision.js +63 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +6 -0
- package/dist/scheduler/locks.d.ts +18 -0
- package/dist/scheduler/locks.js +106 -0
- package/dist/scheduler/ready-queue.d.ts +32 -0
- package/dist/scheduler/ready-queue.js +40 -0
- package/dist/scheduler/runtime.d.ts +486 -0
- package/dist/scheduler/runtime.js +3445 -0
- package/dist/scheduler/telemetry.d.ts +111 -0
- package/dist/scheduler/telemetry.js +177 -0
- package/dist/scheduler/worker.d.ts +158 -0
- package/dist/scheduler/worker.js +744 -0
- package/dist/storage/artifacts.d.ts +17 -0
- package/dist/storage/artifacts.js +90 -0
- package/dist/storage/findings.d.ts +12 -0
- package/dist/storage/findings.js +70 -0
- package/dist/storage/index.d.ts +8 -0
- package/dist/storage/index.js +8 -0
- package/dist/storage/memory.d.ts +11 -0
- package/dist/storage/memory.js +21 -0
- package/dist/storage/mutation-log.d.ts +41 -0
- package/dist/storage/mutation-log.js +140 -0
- package/dist/storage/outbox.d.ts +30 -0
- package/dist/storage/outbox.js +59 -0
- package/dist/storage/persistence.d.ts +183 -0
- package/dist/storage/persistence.js +999 -0
- package/dist/storage/policy.d.ts +80 -0
- package/dist/storage/policy.js +268 -0
- package/dist/storage/session.d.ts +140 -0
- package/dist/storage/session.js +447 -0
- package/dist/tools/registry.d.ts +125 -0
- package/dist/tools/registry.js +308 -0
- package/dist/transitions/index.d.ts +2 -0
- package/dist/transitions/index.js +1 -0
- package/dist/transitions/validate.d.ts +4 -0
- package/dist/transitions/validate.js +1118 -0
- package/package.json +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ArtifactRecord, ArtifactRef, LaneId, PrivacyLabel, PrivacyTaint, ProvenanceRef, RuntimeState } from '../core/types.js';
|
|
2
|
+
export interface ArtifactPublication {
|
|
3
|
+
mediaType: string;
|
|
4
|
+
content: Uint8Array | string;
|
|
5
|
+
laneId?: LaneId;
|
|
6
|
+
ref?: ArtifactRef;
|
|
7
|
+
privacy?: PrivacyLabel;
|
|
8
|
+
privacyTaints?: PrivacyTaint[];
|
|
9
|
+
derivedFrom?: ProvenanceRef[];
|
|
10
|
+
}
|
|
11
|
+
export declare function prepareArtifactPublication(state: RuntimeState, publication: ArtifactPublication): ArtifactRecord;
|
|
12
|
+
export declare function publishArtifact(state: RuntimeState, publication: ArtifactPublication): ArtifactRecord;
|
|
13
|
+
export declare function advanceArtifactId(state: RuntimeState, ref: ArtifactRef): void;
|
|
14
|
+
export declare function readArtifact(state: RuntimeState, ref: ArtifactRef): Uint8Array;
|
|
15
|
+
export declare function pinArtifact(state: RuntimeState, ref: ArtifactRef): void;
|
|
16
|
+
export declare function unpinArtifact(state: RuntimeState, ref: ArtifactRef): void;
|
|
17
|
+
export declare function markArtifactPersisted(state: RuntimeState, ref: ArtifactRef): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { effectivePrivacy, privacyMetadataForDerivedRef, privacyTaintsForDerivedRefs, privacyRank, strictestPrivacy, validatePrivacyTaints } from '../core/types.js';
|
|
3
|
+
function bytesOf(content) { return typeof content === 'string' ? Buffer.from(content, 'utf8') : new Uint8Array(content); }
|
|
4
|
+
function mergeTaints(...groups) {
|
|
5
|
+
const output = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
for (const group of groups)
|
|
8
|
+
for (const taint of group ?? []) {
|
|
9
|
+
const key = JSON.stringify(taint);
|
|
10
|
+
if (!seen.has(key)) {
|
|
11
|
+
seen.add(key);
|
|
12
|
+
output.push(structuredClone(taint));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return output;
|
|
16
|
+
}
|
|
17
|
+
export function prepareArtifactPublication(state, publication) {
|
|
18
|
+
if (!publication.mediaType || publication.mediaType.length > 255)
|
|
19
|
+
throw new Error('INVALID_ARTIFACT_MEDIA_TYPE');
|
|
20
|
+
const taintError = validatePrivacyTaints(publication.privacyTaints);
|
|
21
|
+
if (taintError)
|
|
22
|
+
throw new Error(taintError);
|
|
23
|
+
const lane = publication.laneId === undefined ? undefined : state.lanes.get(publication.laneId);
|
|
24
|
+
if (publication.laneId !== undefined && !lane)
|
|
25
|
+
throw new Error('UNKNOWN_LANE');
|
|
26
|
+
const refs = publication.derivedFrom ?? [];
|
|
27
|
+
const sourceMetadata = refs.map((ref) => lane ? privacyMetadataForDerivedRef(state, lane, ref) : undefined);
|
|
28
|
+
if (sourceMetadata.some((metadata) => metadata === undefined))
|
|
29
|
+
throw new Error('UNKNOWN_ARTIFACT_SOURCE');
|
|
30
|
+
const sourcePrivacy = sourceMetadata.map((metadata) => effectivePrivacy(metadata.privacy, metadata.privacyTaints));
|
|
31
|
+
const privacy = strictestPrivacy([publication.privacy ?? 'public', ...sourcePrivacy]);
|
|
32
|
+
if (publication.privacy !== undefined && privacyRank(publication.privacy) < privacyRank(strictestPrivacy(sourcePrivacy)))
|
|
33
|
+
throw new Error('PRIVACY_DOWNGRADE_WITHOUT_PROOF');
|
|
34
|
+
const sourceTaints = lane ? privacyTaintsForDerivedRefs(state, lane, refs) : [];
|
|
35
|
+
const content = bytesOf(publication.content);
|
|
36
|
+
const ref = publication.ref ?? `artifact-${state.nextIds.artifact}`;
|
|
37
|
+
if (state.artifacts.has(ref))
|
|
38
|
+
throw new Error('ARTIFACT_REF_ALREADY_EXISTS');
|
|
39
|
+
const record = {
|
|
40
|
+
ref,
|
|
41
|
+
...(lane ? { agentId: lane.agentId } : {}),
|
|
42
|
+
mediaType: publication.mediaType,
|
|
43
|
+
sizeBytes: content.byteLength,
|
|
44
|
+
contentHash: createHash('sha256').update(content).digest('hex'),
|
|
45
|
+
contentBase64: Buffer.from(content).toString('base64'),
|
|
46
|
+
privacy,
|
|
47
|
+
...(mergeTaints(sourceTaints, publication.privacyTaints).length ? { privacyTaints: mergeTaints(sourceTaints, publication.privacyTaints) } : {}),
|
|
48
|
+
...(refs.length ? { derivedFrom: [...refs] } : {}),
|
|
49
|
+
storageState: 'memory',
|
|
50
|
+
pinCount: 0,
|
|
51
|
+
};
|
|
52
|
+
return structuredClone(record);
|
|
53
|
+
}
|
|
54
|
+
export function publishArtifact(state, publication) {
|
|
55
|
+
const record = prepareArtifactPublication(state, publication);
|
|
56
|
+
state.artifacts.set(record.ref, record);
|
|
57
|
+
advanceArtifactId(state, record.ref);
|
|
58
|
+
return structuredClone(record);
|
|
59
|
+
}
|
|
60
|
+
export function advanceArtifactId(state, ref) {
|
|
61
|
+
const match = /^artifact-(\d+)$/.exec(ref);
|
|
62
|
+
if (match)
|
|
63
|
+
state.nextIds.artifact = Math.max(state.nextIds.artifact, Number(match[1]) + 1);
|
|
64
|
+
}
|
|
65
|
+
export function readArtifact(state, ref) {
|
|
66
|
+
const record = state.artifacts.get(ref);
|
|
67
|
+
if (!record)
|
|
68
|
+
throw new Error('UNKNOWN_ARTIFACT_REF');
|
|
69
|
+
return new Uint8Array(Buffer.from(record.contentBase64, 'base64'));
|
|
70
|
+
}
|
|
71
|
+
export function pinArtifact(state, ref) {
|
|
72
|
+
const record = state.artifacts.get(ref);
|
|
73
|
+
if (!record)
|
|
74
|
+
throw new Error('UNKNOWN_ARTIFACT_REF');
|
|
75
|
+
record.pinCount++;
|
|
76
|
+
}
|
|
77
|
+
export function unpinArtifact(state, ref) {
|
|
78
|
+
const record = state.artifacts.get(ref);
|
|
79
|
+
if (!record)
|
|
80
|
+
throw new Error('UNKNOWN_ARTIFACT_REF');
|
|
81
|
+
if (record.pinCount === 0)
|
|
82
|
+
throw new Error('ARTIFACT_NOT_PINNED');
|
|
83
|
+
record.pinCount--;
|
|
84
|
+
}
|
|
85
|
+
export function markArtifactPersisted(state, ref) {
|
|
86
|
+
const record = state.artifacts.get(ref);
|
|
87
|
+
if (!record)
|
|
88
|
+
throw new Error('UNKNOWN_ARTIFACT_REF');
|
|
89
|
+
record.storageState = 'persisted';
|
|
90
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DataRef, FindingRecord, LaneId, PrivacyLabel, PrivacyTaint, RuntimeState } from '../core/types.js';
|
|
2
|
+
export interface FindingPublication {
|
|
3
|
+
statement: string;
|
|
4
|
+
evidenceRefs: DataRef[];
|
|
5
|
+
laneId: LaneId;
|
|
6
|
+
ref?: string;
|
|
7
|
+
privacy?: PrivacyLabel;
|
|
8
|
+
privacyTaints?: PrivacyTaint[];
|
|
9
|
+
}
|
|
10
|
+
export declare function prepareFindingPublication(state: RuntimeState, publication: FindingPublication): FindingRecord;
|
|
11
|
+
export declare function publishFinding(state: RuntimeState, publication: FindingPublication): FindingRecord;
|
|
12
|
+
export declare function advanceFindingId(state: RuntimeState, ref: string): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { effectivePrivacy, privacyMetadataForDerivedRef, privacyRank, privacyTaintsForDerivedRefs, provenanceRefId, strictestPrivacy, validatePrivacyTaints } from '../core/types.js';
|
|
2
|
+
import { contentHash, stableSerialize } from '../context/builder.js';
|
|
3
|
+
function sourceVisible(state, laneId, ref) {
|
|
4
|
+
const lane = state.lanes.get(laneId);
|
|
5
|
+
if (!lane)
|
|
6
|
+
return false;
|
|
7
|
+
if (ref.kind === 'result')
|
|
8
|
+
return lane.visibleResultRefs === undefined || lane.visibleResultRefs.has(ref.ref);
|
|
9
|
+
const artifact = state.artifacts.get(ref.ref);
|
|
10
|
+
return artifact !== undefined && (artifact.agentId === undefined || artifact.agentId === lane.agentId);
|
|
11
|
+
}
|
|
12
|
+
export function prepareFindingPublication(state, publication) {
|
|
13
|
+
if (!publication.statement.trim() || publication.statement.length > 4096)
|
|
14
|
+
throw new Error('INVALID_FINDING_STATEMENT');
|
|
15
|
+
if (publication.evidenceRefs.length === 0)
|
|
16
|
+
throw new Error('FINDING_REQUIRES_EVIDENCE');
|
|
17
|
+
const taintError = validatePrivacyTaints(publication.privacyTaints);
|
|
18
|
+
if (taintError)
|
|
19
|
+
throw new Error(taintError);
|
|
20
|
+
const lane = state.lanes.get(publication.laneId);
|
|
21
|
+
if (!lane)
|
|
22
|
+
throw new Error('UNKNOWN_LANE');
|
|
23
|
+
for (const ref of publication.evidenceRefs) {
|
|
24
|
+
if (!sourceVisible(state, lane.id, ref) || !privacyMetadataForDerivedRef(state, lane, ref))
|
|
25
|
+
throw new Error(`UNKNOWN_FINDING_EVIDENCE:${provenanceRefId(ref)}`);
|
|
26
|
+
}
|
|
27
|
+
const sourceMetadata = publication.evidenceRefs.map((ref) => privacyMetadataForDerivedRef(state, lane, ref));
|
|
28
|
+
const sourcePrivacy = sourceMetadata.map((metadata) => effectivePrivacy(metadata.privacy, metadata.privacyTaints));
|
|
29
|
+
const privacy = strictestPrivacy([publication.privacy ?? 'public', ...sourcePrivacy]);
|
|
30
|
+
if (publication.privacy !== undefined && privacyRank(publication.privacy) < privacyRank(strictestPrivacy(sourcePrivacy)))
|
|
31
|
+
throw new Error('PRIVACY_DOWNGRADE_WITHOUT_PROOF');
|
|
32
|
+
const sourceTaints = privacyTaintsForDerivedRefs(state, lane, publication.evidenceRefs);
|
|
33
|
+
const ref = publication.ref ?? `finding-${state.nextIds.result}`;
|
|
34
|
+
if (state.results.has(ref))
|
|
35
|
+
throw new Error('FINDING_REF_ALREADY_EXISTS');
|
|
36
|
+
const record = {
|
|
37
|
+
id: ref,
|
|
38
|
+
producer: { kind: 'lane', id: lane.id },
|
|
39
|
+
kind: 'finding',
|
|
40
|
+
agentId: lane.agentId,
|
|
41
|
+
laneId: lane.id,
|
|
42
|
+
statement: publication.statement,
|
|
43
|
+
evidenceRefs: structuredClone(publication.evidenceRefs),
|
|
44
|
+
value: { statement: publication.statement, evidenceRefs: structuredClone(publication.evidenceRefs) },
|
|
45
|
+
sizeBytes: Buffer.byteLength(stableSerialize({ statement: publication.statement, evidenceRefs: publication.evidenceRefs }), 'utf8'),
|
|
46
|
+
contentHash: contentHash({ statement: publication.statement, evidenceRefs: publication.evidenceRefs }),
|
|
47
|
+
storageState: 'memory',
|
|
48
|
+
pinCount: 0,
|
|
49
|
+
privacy,
|
|
50
|
+
...(sourceTaints.length || publication.privacyTaints?.length ? { privacyTaints: [...sourceTaints, ...(publication.privacyTaints ?? [])] } : {}),
|
|
51
|
+
derivedFrom: structuredClone(publication.evidenceRefs),
|
|
52
|
+
};
|
|
53
|
+
return structuredClone(record);
|
|
54
|
+
}
|
|
55
|
+
export function publishFinding(state, publication) {
|
|
56
|
+
const record = prepareFindingPublication(state, publication);
|
|
57
|
+
state.results.set(record.id, record);
|
|
58
|
+
const lane = state.lanes.get(record.laneId);
|
|
59
|
+
if (lane?.visibleResultRefs)
|
|
60
|
+
lane.visibleResultRefs.add(record.id);
|
|
61
|
+
else if (lane)
|
|
62
|
+
lane.visibleResultRefs = new Set([record.id]);
|
|
63
|
+
advanceFindingId(state, record.id);
|
|
64
|
+
return structuredClone(record);
|
|
65
|
+
}
|
|
66
|
+
export function advanceFindingId(state, ref) {
|
|
67
|
+
const match = /^finding-(\d+)$/.exec(ref);
|
|
68
|
+
if (match)
|
|
69
|
+
state.nextIds.result = Math.max(state.nextIds.result, Number(match[1]) + 1);
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { JsonValue } from '../core/types.js';
|
|
2
|
+
export declare class MemoryStorage {
|
|
3
|
+
readonly hardLimitBytes: number;
|
|
4
|
+
private readonly values;
|
|
5
|
+
private bytes;
|
|
6
|
+
constructor(hardLimitBytes?: number);
|
|
7
|
+
put(key: string, value: JsonValue): void;
|
|
8
|
+
get(key: string): JsonValue | undefined;
|
|
9
|
+
delete(key: string): boolean;
|
|
10
|
+
get sizeBytes(): number;
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { stableSerialize } from '../context/builder.js';
|
|
2
|
+
export class MemoryStorage {
|
|
3
|
+
hardLimitBytes;
|
|
4
|
+
values = new Map();
|
|
5
|
+
bytes = 0;
|
|
6
|
+
constructor(hardLimitBytes = 1_000_000) {
|
|
7
|
+
this.hardLimitBytes = hardLimitBytes;
|
|
8
|
+
}
|
|
9
|
+
put(key, value) {
|
|
10
|
+
const nextBytes = Buffer.byteLength(stableSerialize(value));
|
|
11
|
+
const previous = this.values.has(key) ? Buffer.byteLength(stableSerialize(this.values.get(key))) : 0;
|
|
12
|
+
if (this.bytes - previous + nextBytes > this.hardLimitBytes)
|
|
13
|
+
throw new Error('SESSION_STORAGE_LIMIT_EXCEEDED');
|
|
14
|
+
this.values.set(key, structuredClone(value));
|
|
15
|
+
this.bytes = this.bytes - previous + nextBytes;
|
|
16
|
+
}
|
|
17
|
+
get(key) { const value = this.values.get(key); return value === undefined ? undefined : structuredClone(value); }
|
|
18
|
+
delete(key) { const value = this.values.get(key); if (value === undefined)
|
|
19
|
+
return false; this.bytes -= Buffer.byteLength(stableSerialize(value)); return this.values.delete(key); }
|
|
20
|
+
get sizeBytes() { return this.bytes; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { JsonValue, RuntimeState } from '../core/types.js';
|
|
2
|
+
import type { Mutation } from '../core/mutations.js';
|
|
3
|
+
export interface MutationLogEntry {
|
|
4
|
+
seq: number;
|
|
5
|
+
transactionId: string;
|
|
6
|
+
committedAt: number;
|
|
7
|
+
checksum: string;
|
|
8
|
+
mutations: Mutation[];
|
|
9
|
+
}
|
|
10
|
+
export interface MutationLogSnapshot {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
baseSeq?: number;
|
|
13
|
+
nextSeq: number;
|
|
14
|
+
entries: Array<{
|
|
15
|
+
seq: number;
|
|
16
|
+
transactionId: string;
|
|
17
|
+
committedAt: number;
|
|
18
|
+
checksum: string;
|
|
19
|
+
mutations: JsonValue;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export declare class MutationLog {
|
|
23
|
+
private readonly log;
|
|
24
|
+
private nextSequence;
|
|
25
|
+
private baseSequence;
|
|
26
|
+
constructor(entries?: MutationLogEntry[], baseSequence?: number);
|
|
27
|
+
get entries(): MutationLogEntry[];
|
|
28
|
+
get size(): number;
|
|
29
|
+
get watermark(): number;
|
|
30
|
+
get lastSequence(): number;
|
|
31
|
+
findTransaction(transactionId: string): MutationLogEntry | undefined;
|
|
32
|
+
prepareAppend(transactionId: string, mutations: Mutation[], committedAt?: number): MutationLogEntry;
|
|
33
|
+
commitPrepared(entry: MutationLogEntry): MutationLogEntry;
|
|
34
|
+
append(transactionId: string, mutations: Mutation[], committedAt?: number): MutationLogEntry;
|
|
35
|
+
snapshot(): MutationLogSnapshot;
|
|
36
|
+
truncateThrough(seq: number): void;
|
|
37
|
+
static fromSnapshot(snapshot: MutationLogSnapshot | JsonValue): MutationLog;
|
|
38
|
+
replay(state: RuntimeState, entries?: MutationLogEntry[]): void;
|
|
39
|
+
private validate;
|
|
40
|
+
}
|
|
41
|
+
export declare function commitMutationTransaction(state: RuntimeState, log: MutationLog, transactionId: string, mutations: Mutation[], committedAt?: number, sessionId?: string): MutationLogEntry;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { apply } from '../core/mutations.js';
|
|
3
|
+
function encode(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return { $pulseType: 'undefined' };
|
|
6
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number')
|
|
7
|
+
return value;
|
|
8
|
+
if (Array.isArray(value))
|
|
9
|
+
return value.map(encode);
|
|
10
|
+
if (value instanceof Map)
|
|
11
|
+
return { $pulseType: 'map', entries: [...value.entries()].map(([key, item]) => [encode(key), encode(item)]) };
|
|
12
|
+
if (value instanceof Set)
|
|
13
|
+
return { $pulseType: 'set', values: [...value.values()].map(encode) };
|
|
14
|
+
if (typeof value === 'object') {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const [key, item] of Object.entries(value))
|
|
17
|
+
result[key] = encode(item);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
throw new Error('UNSERIALIZABLE_MUTATION_VALUE');
|
|
21
|
+
}
|
|
22
|
+
function decode(value) {
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number')
|
|
24
|
+
return value;
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return value.map(decode);
|
|
27
|
+
if (!value || typeof value !== 'object')
|
|
28
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
29
|
+
const object = value;
|
|
30
|
+
if (object.$pulseType === 'undefined')
|
|
31
|
+
return undefined;
|
|
32
|
+
if (object.$pulseType === 'map')
|
|
33
|
+
return new Map(object.entries.map((entry) => { const pair = entry; return [decode(pair[0]), decode(pair[1])]; }));
|
|
34
|
+
if (object.$pulseType === 'set')
|
|
35
|
+
return new Set(object.values.map(decode));
|
|
36
|
+
return Object.fromEntries(Object.entries(object).map(([key, item]) => [key, decode(item)]));
|
|
37
|
+
}
|
|
38
|
+
function checksum(seq, transactionId, mutations) {
|
|
39
|
+
return createHash('sha256').update(JSON.stringify(encode({ seq, transactionId, mutations }))).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function cloneMutations(mutations) { return structuredClone(mutations); }
|
|
42
|
+
export class MutationLog {
|
|
43
|
+
log;
|
|
44
|
+
nextSequence;
|
|
45
|
+
baseSequence;
|
|
46
|
+
constructor(entries = [], baseSequence = 0) {
|
|
47
|
+
this.log = entries.map((entry) => ({ ...entry, mutations: cloneMutations(entry.mutations) }));
|
|
48
|
+
this.baseSequence = baseSequence;
|
|
49
|
+
this.nextSequence = this.log.length ? Math.max(...this.log.map((entry) => entry.seq)) + 1 : baseSequence + 1;
|
|
50
|
+
this.validate();
|
|
51
|
+
}
|
|
52
|
+
get entries() { return this.log.map((entry) => ({ ...entry, mutations: cloneMutations(entry.mutations) })); }
|
|
53
|
+
get size() { return this.log.length; }
|
|
54
|
+
get watermark() { return this.baseSequence; }
|
|
55
|
+
get lastSequence() { return this.log.at(-1)?.seq ?? this.baseSequence; }
|
|
56
|
+
findTransaction(transactionId) {
|
|
57
|
+
const entry = this.log.find((candidate) => candidate.transactionId === transactionId);
|
|
58
|
+
return entry === undefined ? undefined : { ...entry, mutations: cloneMutations(entry.mutations) };
|
|
59
|
+
}
|
|
60
|
+
prepareAppend(transactionId, mutations, committedAt = 0) {
|
|
61
|
+
if (!transactionId)
|
|
62
|
+
throw new Error('INVALID_TRANSACTION_ID');
|
|
63
|
+
const existing = this.findTransaction(transactionId);
|
|
64
|
+
if (existing)
|
|
65
|
+
return existing;
|
|
66
|
+
const seq = this.nextSequence;
|
|
67
|
+
const clonedMutations = cloneMutations(mutations);
|
|
68
|
+
return { seq, transactionId, committedAt, mutations: clonedMutations, checksum: checksum(seq, transactionId, clonedMutations) };
|
|
69
|
+
}
|
|
70
|
+
commitPrepared(entry) {
|
|
71
|
+
const existing = this.findTransaction(entry.transactionId);
|
|
72
|
+
if (existing)
|
|
73
|
+
return existing;
|
|
74
|
+
if (entry.seq !== this.nextSequence || checksum(entry.seq, entry.transactionId, entry.mutations) !== entry.checksum)
|
|
75
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
76
|
+
this.log.push({ ...entry, mutations: cloneMutations(entry.mutations) });
|
|
77
|
+
this.nextSequence = entry.seq + 1;
|
|
78
|
+
return { ...entry, mutations: cloneMutations(entry.mutations) };
|
|
79
|
+
}
|
|
80
|
+
append(transactionId, mutations, committedAt = 0) {
|
|
81
|
+
return this.commitPrepared(this.prepareAppend(transactionId, mutations, committedAt));
|
|
82
|
+
}
|
|
83
|
+
snapshot() {
|
|
84
|
+
return { schemaVersion: 1, ...(this.baseSequence === 0 ? {} : { baseSeq: this.baseSequence }), nextSeq: this.nextSequence, entries: this.log.map((entry) => ({ seq: entry.seq, transactionId: entry.transactionId, committedAt: entry.committedAt, checksum: entry.checksum, mutations: encode(entry.mutations) })) };
|
|
85
|
+
}
|
|
86
|
+
truncateThrough(seq) {
|
|
87
|
+
if (!Number.isInteger(seq) || seq < this.baseSequence || seq > this.lastSequence)
|
|
88
|
+
throw new Error('INVALID_CHECKPOINT_WATERMARK');
|
|
89
|
+
while (this.log[0] && this.log[0].seq <= seq)
|
|
90
|
+
this.log.shift();
|
|
91
|
+
this.baseSequence = seq;
|
|
92
|
+
this.nextSequence = this.log[0]?.seq ?? seq + 1;
|
|
93
|
+
this.validate();
|
|
94
|
+
}
|
|
95
|
+
static fromSnapshot(snapshot) {
|
|
96
|
+
const value = snapshot;
|
|
97
|
+
if (!value || value.schemaVersion !== 1 || !Number.isInteger(value.nextSeq) || !Array.isArray(value.entries) || (value.baseSeq !== undefined && (!Number.isInteger(value.baseSeq) || value.baseSeq < 0)))
|
|
98
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
99
|
+
const entries = value.entries.map((entry) => {
|
|
100
|
+
if (!entry || !Number.isInteger(entry.seq) || typeof entry.transactionId !== 'string' || typeof entry.committedAt !== 'number' || typeof entry.checksum !== 'string')
|
|
101
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
102
|
+
const mutations = decode(entry.mutations);
|
|
103
|
+
if (!Array.isArray(mutations))
|
|
104
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
105
|
+
return { seq: entry.seq, transactionId: entry.transactionId, committedAt: entry.committedAt, checksum: entry.checksum, mutations: mutations };
|
|
106
|
+
});
|
|
107
|
+
const log = new MutationLog(entries, value.baseSeq ?? 0);
|
|
108
|
+
if (log.nextSequence !== value.nextSeq)
|
|
109
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
110
|
+
return log;
|
|
111
|
+
}
|
|
112
|
+
replay(state, entries = this.log) {
|
|
113
|
+
let expected = this.baseSequence + 1;
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.seq !== expected || checksum(entry.seq, entry.transactionId, entry.mutations) !== entry.checksum)
|
|
116
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
117
|
+
apply(state, cloneMutations(entry.mutations));
|
|
118
|
+
expected++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
validate() {
|
|
122
|
+
let expected = this.baseSequence + 1;
|
|
123
|
+
for (const entry of this.log) {
|
|
124
|
+
if (entry.seq !== expected || checksum(entry.seq, entry.transactionId, entry.mutations) !== entry.checksum)
|
|
125
|
+
throw new Error('INVALID_MUTATION_LOG');
|
|
126
|
+
expected++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function commitMutationTransaction(state, log, transactionId, mutations, committedAt = state.now, sessionId = 'session-unknown') {
|
|
131
|
+
const existing = log.findTransaction(transactionId);
|
|
132
|
+
if (existing)
|
|
133
|
+
return existing;
|
|
134
|
+
const transactionalMutations = mutations.map((mutation) => mutation.op === 'appendEvent' && mutation.event.txId === undefined ? { ...mutation, event: { ...mutation.event, txId: transactionId } } : mutation);
|
|
135
|
+
const candidate = structuredClone(state);
|
|
136
|
+
apply(candidate, transactionalMutations, { sessionId, timestamp: committedAt });
|
|
137
|
+
const entry = log.prepareAppend(transactionId, transactionalMutations, committedAt);
|
|
138
|
+
apply(state, cloneMutations(entry.mutations), { sessionId, timestamp: committedAt });
|
|
139
|
+
return log.commitPrepared(entry);
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EffectRecord, JsonValue, RuntimeState } from '../core/types.js';
|
|
2
|
+
export interface OutboxEntry {
|
|
3
|
+
id: string;
|
|
4
|
+
effectId: string;
|
|
5
|
+
attemptId: string;
|
|
6
|
+
state: 'pending' | 'claimed';
|
|
7
|
+
createdAt: number;
|
|
8
|
+
claimCount: number;
|
|
9
|
+
}
|
|
10
|
+
export interface OutboxSnapshot {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
entries: OutboxEntry[];
|
|
13
|
+
}
|
|
14
|
+
export declare class EffectOutbox {
|
|
15
|
+
private readonly entries;
|
|
16
|
+
enqueue(effect: Pick<EffectRecord, 'id' | 'attemptId'>, createdAt?: number): OutboxEntry;
|
|
17
|
+
claim(id: string): OutboxEntry | undefined;
|
|
18
|
+
requeue(id: string): void;
|
|
19
|
+
ack(id: string): boolean;
|
|
20
|
+
get(id: string): OutboxEntry | undefined;
|
|
21
|
+
get size(): number;
|
|
22
|
+
pending(): OutboxEntry[];
|
|
23
|
+
claimed(): OutboxEntry[];
|
|
24
|
+
snapshot(): OutboxSnapshot;
|
|
25
|
+
static fromSnapshot(snapshot: OutboxSnapshot | JsonValue): EffectOutbox;
|
|
26
|
+
recover(state: RuntimeState): {
|
|
27
|
+
requeued: string[];
|
|
28
|
+
unknown: string[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export class EffectOutbox {
|
|
2
|
+
entries = new Map();
|
|
3
|
+
enqueue(effect, createdAt = 0) {
|
|
4
|
+
const id = `${effect.id}:${effect.attemptId}`;
|
|
5
|
+
const existing = this.entries.get(id);
|
|
6
|
+
if (existing)
|
|
7
|
+
return { ...existing };
|
|
8
|
+
const entry = { id, effectId: effect.id, attemptId: effect.attemptId, state: 'pending', createdAt, claimCount: 0 };
|
|
9
|
+
this.entries.set(id, entry);
|
|
10
|
+
return { ...entry };
|
|
11
|
+
}
|
|
12
|
+
claim(id) {
|
|
13
|
+
const entry = this.entries.get(id);
|
|
14
|
+
if (!entry || entry.state === 'claimed')
|
|
15
|
+
return undefined;
|
|
16
|
+
entry.state = 'claimed';
|
|
17
|
+
entry.claimCount++;
|
|
18
|
+
return { ...entry };
|
|
19
|
+
}
|
|
20
|
+
requeue(id) { const entry = this.entries.get(id); if (entry)
|
|
21
|
+
entry.state = 'pending'; }
|
|
22
|
+
ack(id) { return this.entries.delete(id); }
|
|
23
|
+
get(id) { const entry = this.entries.get(id); return entry === undefined ? undefined : { ...entry }; }
|
|
24
|
+
get size() { return this.entries.size; }
|
|
25
|
+
pending() { return [...this.entries.values()].filter((entry) => entry.state === 'pending').map((entry) => ({ ...entry })); }
|
|
26
|
+
claimed() { return [...this.entries.values()].filter((entry) => entry.state === 'claimed').map((entry) => ({ ...entry })); }
|
|
27
|
+
snapshot() { return { schemaVersion: 1, entries: [...this.entries.values()].map((entry) => ({ ...entry })) }; }
|
|
28
|
+
static fromSnapshot(snapshot) {
|
|
29
|
+
const value = snapshot;
|
|
30
|
+
if (!value || value.schemaVersion !== 1 || !Array.isArray(value.entries))
|
|
31
|
+
throw new Error('INVALID_OUTBOX_SNAPSHOT');
|
|
32
|
+
const outbox = new EffectOutbox();
|
|
33
|
+
for (const entry of value.entries) {
|
|
34
|
+
if (!entry || typeof entry.id !== 'string' || entry.id.length === 0 || typeof entry.effectId !== 'string' || entry.effectId.length === 0 || typeof entry.attemptId !== 'string' || entry.attemptId.length === 0 || entry.id !== `${entry.effectId}:${entry.attemptId}` || !['pending', 'claimed'].includes(entry.state) || typeof entry.createdAt !== 'number' || !Number.isFinite(entry.createdAt) || !Number.isInteger(entry.claimCount) || entry.claimCount < 0 || (entry.state === 'claimed' && entry.claimCount < 1))
|
|
35
|
+
throw new Error('INVALID_OUTBOX_SNAPSHOT');
|
|
36
|
+
if (outbox.entries.has(entry.id))
|
|
37
|
+
throw new Error('INVALID_OUTBOX_SNAPSHOT');
|
|
38
|
+
outbox.entries.set(entry.id, { ...entry });
|
|
39
|
+
}
|
|
40
|
+
return outbox;
|
|
41
|
+
}
|
|
42
|
+
recover(state) {
|
|
43
|
+
const requeued = [];
|
|
44
|
+
const unknown = [];
|
|
45
|
+
for (const entry of this.entries.values()) {
|
|
46
|
+
const effect = state.effects.get(entry.effectId);
|
|
47
|
+
if (!effect || effect.attemptId !== entry.attemptId || effect.outcome) {
|
|
48
|
+
this.entries.delete(entry.id);
|
|
49
|
+
unknown.push(entry.id);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (entry.state === 'claimed') {
|
|
53
|
+
entry.state = 'pending';
|
|
54
|
+
requeued.push(entry.id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { requeued, unknown };
|
|
58
|
+
}
|
|
59
|
+
}
|