@velum-labs/routekit-eval-store 1.0.26 → 1.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/effect-api.d.ts +3 -1
- package/dist/effect-api.js +3 -1
- package/dist/experiment-artifacts.d.ts +19 -0
- package/dist/experiment-artifacts.js +61 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/routing-activation.d.ts +15 -10
- package/dist/routing-activation.js +210 -61
- package/dist/services/experiment-artifacts/service.d.ts +12 -0
- package/dist/services/experiment-artifacts/service.js +4 -0
- package/dist/test/routing-activation.test.js +50 -2
- package/package.json +3 -3
package/dist/effect-api.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export type { RoutingActivationPublication } from "./routing-activation.js";
|
|
2
|
-
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
2
|
+
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingDeploymentConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
3
3
|
export { EvalStore, makeEvalStore } from "./store.js";
|
|
4
|
+
export * from "./experiment-artifacts.js";
|
|
5
|
+
export * from "./services/experiment-artifacts/service.js";
|
package/dist/effect-api.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
1
|
+
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingDeploymentConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
2
2
|
export { EvalStore, makeEvalStore } from "./store.js";
|
|
3
|
+
export * from "./experiment-artifacts.js";
|
|
4
|
+
export * from "./services/experiment-artifacts/service.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RouteKitFailure } from "@velum-labs/routekit-runtime/effect";
|
|
2
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
3
|
+
export declare const EXPERIMENT_ARTIFACT_MAX_BYTES: number;
|
|
4
|
+
export type ExperimentArtifactVisibility = "ordinary" | "private";
|
|
5
|
+
export type ExperimentArtifactReference = Readonly<{
|
|
6
|
+
runId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
visibility: ExperimentArtifactVisibility;
|
|
9
|
+
path: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare class ExperimentArtifactError extends RouteKitFailure {
|
|
12
|
+
}
|
|
13
|
+
export declare class FileExperimentArtifactStore {
|
|
14
|
+
readonly root: string;
|
|
15
|
+
constructor(root: string);
|
|
16
|
+
write(runId: string, name: string, value: unknown, visibility?: ExperimentArtifactVisibility): Effect.Effect<ExperimentArtifactReference, Error, FileSystem.FileSystem | Path.Path>;
|
|
17
|
+
read(runId: string, name: string, visibility?: ExperimentArtifactVisibility): Effect.Effect<string | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
18
|
+
}
|
|
19
|
+
export declare function makeFileExperimentArtifactStore(root: string): FileExperimentArtifactStore;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RouteKitFailure, writeFileAtomicEffect } from "@velum-labs/routekit-runtime/effect";
|
|
2
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
3
|
+
export const EXPERIMENT_ARTIFACT_MAX_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
const SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u;
|
|
5
|
+
export class ExperimentArtifactError extends RouteKitFailure {
|
|
6
|
+
}
|
|
7
|
+
function validateSegment(value, label) {
|
|
8
|
+
if (!SEGMENT.test(value) || value === "." || value === "..")
|
|
9
|
+
throw new ExperimentArtifactError({ message: `${label} is not a valid artifact path segment` });
|
|
10
|
+
}
|
|
11
|
+
export class FileExperimentArtifactStore {
|
|
12
|
+
root;
|
|
13
|
+
constructor(root) {
|
|
14
|
+
this.root = root;
|
|
15
|
+
}
|
|
16
|
+
write(runId, name, value, visibility = "ordinary") {
|
|
17
|
+
const root = this.root;
|
|
18
|
+
return Effect.gen(function* () {
|
|
19
|
+
validateSegment(runId, "run ID");
|
|
20
|
+
validateSegment(name, "artifact name");
|
|
21
|
+
const raw = typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
|
|
22
|
+
if (Buffer.byteLength(raw, "utf8") > EXPERIMENT_ARTIFACT_MAX_BYTES)
|
|
23
|
+
return yield* new ExperimentArtifactError({
|
|
24
|
+
message: "experiment artifact exceeds the size limit"
|
|
25
|
+
});
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const paths = yield* Path.Path;
|
|
28
|
+
const directory = paths.join(root, visibility, runId);
|
|
29
|
+
const path = paths.join(directory, name);
|
|
30
|
+
yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 });
|
|
31
|
+
yield* fs.chmod(directory, 0o700).pipe(Effect.ignore);
|
|
32
|
+
if (yield* fs.exists(path))
|
|
33
|
+
return yield* new ExperimentArtifactError({
|
|
34
|
+
message: `experiment artifact ${runId}/${name} is immutable and already exists`
|
|
35
|
+
});
|
|
36
|
+
yield* writeFileAtomicEffect(path, raw, { mode: 0o600 });
|
|
37
|
+
return { runId, name, visibility, path };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
read(runId, name, visibility = "ordinary") {
|
|
41
|
+
const root = this.root;
|
|
42
|
+
return Effect.gen(function* () {
|
|
43
|
+
validateSegment(runId, "run ID");
|
|
44
|
+
validateSegment(name, "artifact name");
|
|
45
|
+
const fs = yield* FileSystem.FileSystem;
|
|
46
|
+
const paths = yield* Path.Path;
|
|
47
|
+
const path = paths.join(root, visibility, runId, name);
|
|
48
|
+
if (!(yield* fs.exists(path)))
|
|
49
|
+
return undefined;
|
|
50
|
+
const info = yield* fs.stat(path);
|
|
51
|
+
if (Number(info.size) > EXPERIMENT_ARTIFACT_MAX_BYTES)
|
|
52
|
+
return yield* new ExperimentArtifactError({
|
|
53
|
+
message: "experiment artifact exceeds the size limit"
|
|
54
|
+
});
|
|
55
|
+
return yield* fs.readFileString(path);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function makeFileExperimentArtifactStore(root) {
|
|
60
|
+
return new FileExperimentArtifactStore(root);
|
|
61
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export type { RoutingActivationPublication } from "./routing-activation.js";
|
|
2
|
-
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
2
|
+
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingDeploymentConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
3
3
|
export { EvalStore, makeEvalStore } from "./store.js";
|
|
4
|
+
export * from "./experiment-artifacts.js";
|
|
5
|
+
export * from "./services/experiment-artifacts/service.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
1
|
+
export { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingDeploymentConflictError, RoutingActivationStore } from "./routing-activation.js";
|
|
2
2
|
export { EvalStore, makeEvalStore } from "./store.js";
|
|
3
|
+
export * from "./experiment-artifacts.js";
|
|
4
|
+
export * from "./services/experiment-artifacts/service.js";
|
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import { PublishedRoutingActivation } from "@velum-labs/routekit-eval-contracts";
|
|
1
|
+
import { type AnyPublishedRoutingActivation as AnyPublishedRoutingActivationType, type PublishedRoutingActivation as PublishedRoutingActivationType, type RoutingDeploymentStateV1 as RoutingDeploymentStateV1Type } from "@velum-labs/routekit-eval-contracts";
|
|
2
2
|
import { Effect, FileSystem, Path } from "effect";
|
|
3
3
|
export declare const ROUTING_ACTIVATION_MAX_BYTES: number;
|
|
4
|
-
export type RoutingActivationPublication = Omit<
|
|
4
|
+
export type RoutingActivationPublication = Omit<PublishedRoutingActivationType, "version" | "generatedAt">;
|
|
5
5
|
export declare class RoutingActivationConflictError extends Error {
|
|
6
6
|
readonly expectedEvidenceDigest: string | undefined;
|
|
7
7
|
readonly actualEvidenceDigest: string | undefined;
|
|
8
8
|
constructor(expectedEvidenceDigest: string | undefined, actualEvidenceDigest: string | undefined);
|
|
9
9
|
}
|
|
10
|
+
export declare class RoutingDeploymentConflictError extends Error {
|
|
11
|
+
readonly slot: "authoritative" | "previousAuthoritative";
|
|
12
|
+
readonly expectedRevisionDigest: string | null;
|
|
13
|
+
readonly actualRevisionDigest: string | null;
|
|
14
|
+
constructor(slot: "authoritative" | "previousAuthoritative", expectedRevisionDigest: string | null, actualRevisionDigest: string | null);
|
|
15
|
+
}
|
|
10
16
|
export declare class RoutingActivationStore {
|
|
11
17
|
#private;
|
|
12
18
|
readonly root: string;
|
|
13
19
|
constructor(root: string);
|
|
14
|
-
read(): Effect.Effect<
|
|
15
|
-
readPrevious(): Effect.Effect<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
publishIfCurrent(publication: RoutingActivationPublication, expectedEvidenceDigest: string | undefined): Effect.Effect<PublishedRoutingActivation, Error, FileSystem.FileSystem | Path.Path>;
|
|
20
|
+
read(): Effect.Effect<PublishedRoutingActivationType | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
21
|
+
readPrevious(): Effect.Effect<PublishedRoutingActivationType | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
22
|
+
readDeployment(): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
23
|
+
publish(publication: RoutingActivationPublication): Effect.Effect<PublishedRoutingActivationType, Error, FileSystem.FileSystem | Path.Path>;
|
|
24
|
+
publishIfCurrent(publication: RoutingActivationPublication, expectedEvidenceDigest: string | undefined): Effect.Effect<PublishedRoutingActivationType, Error, FileSystem.FileSystem | Path.Path>;
|
|
25
|
+
installAuthoritative(activation: AnyPublishedRoutingActivationType, expectedAuthoritativeRevisionDigest: string | null, expectedPreviousAuthoritativeRevisionDigest: string | null): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
26
|
+
rollbackAuthoritative(expectedAuthoritativeRevisionDigest: string, expectedPreviousAuthoritativeRevisionDigest: string): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
22
27
|
}
|
|
23
28
|
export declare function makeRoutingActivationStore(root: string): RoutingActivationStore;
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
import { assertPublishedRoutingActivation, COMPOSITIONAL_ROUTING_VERSION, PublishedRoutingActivation } from "@velum-labs/routekit-eval-contracts";
|
|
1
|
+
import { activationRevisionDigest, assertPublishedRoutingActivation, assertPublishedRoutingActivationV3, assertRoutingDeploymentStateV1, COMPOSITIONAL_ROUTING_VERSION, PublishedRoutingActivation, RoutingDeploymentStateV1, routingActivationRevision } from "@velum-labs/routekit-eval-contracts";
|
|
2
2
|
import { RouteKitFailure, writeFileAtomicEffect } from "@velum-labs/routekit-runtime/effect";
|
|
3
3
|
import { Clock, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
4
|
const SNAPSHOT_FILE = "published-routing.json";
|
|
5
5
|
const PREVIOUS_SNAPSHOT_FILE = "published-routing.previous.json";
|
|
6
|
+
const DEPLOYMENT_FILE = "routing-deployment.v1.json";
|
|
6
7
|
export const ROUTING_ACTIVATION_MAX_BYTES = 2 * 1024 * 1024;
|
|
7
8
|
const publicationTails = new Map();
|
|
8
9
|
export class RoutingActivationConflictError extends Error {
|
|
9
10
|
expectedEvidenceDigest;
|
|
10
11
|
actualEvidenceDigest;
|
|
11
12
|
constructor(expectedEvidenceDigest, actualEvidenceDigest) {
|
|
12
|
-
super(`published routing activation changed: expected ${expectedEvidenceDigest === undefined
|
|
13
|
-
? "no activation"
|
|
14
|
-
: JSON.stringify(expectedEvidenceDigest)}, found ${actualEvidenceDigest === undefined ? "no activation" : JSON.stringify(actualEvidenceDigest)}`);
|
|
13
|
+
super(`published routing activation changed: expected ${expectedEvidenceDigest === undefined ? "no activation" : JSON.stringify(expectedEvidenceDigest)}, found ${actualEvidenceDigest === undefined ? "no activation" : JSON.stringify(actualEvidenceDigest)}`);
|
|
15
14
|
this.name = "RoutingActivationConflictError";
|
|
16
15
|
this.expectedEvidenceDigest = expectedEvidenceDigest;
|
|
17
16
|
this.actualEvidenceDigest = actualEvidenceDigest;
|
|
18
17
|
}
|
|
19
18
|
}
|
|
19
|
+
export class RoutingDeploymentConflictError extends Error {
|
|
20
|
+
slot;
|
|
21
|
+
expectedRevisionDigest;
|
|
22
|
+
actualRevisionDigest;
|
|
23
|
+
constructor(slot, expectedRevisionDigest, actualRevisionDigest) {
|
|
24
|
+
super(`routing deployment ${slot} changed: expected ${expectedRevisionDigest === null ? "no revision" : JSON.stringify(expectedRevisionDigest)}, found ${actualRevisionDigest === null ? "no revision" : JSON.stringify(actualRevisionDigest)}`);
|
|
25
|
+
this.slot = slot;
|
|
26
|
+
this.expectedRevisionDigest = expectedRevisionDigest;
|
|
27
|
+
this.actualRevisionDigest = actualRevisionDigest;
|
|
28
|
+
this.name = "RoutingDeploymentConflictError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
20
31
|
export class RoutingActivationStore {
|
|
21
32
|
root;
|
|
22
33
|
constructor(root) {
|
|
@@ -37,44 +48,89 @@ export class RoutingActivationStore {
|
|
|
37
48
|
publicationTails.delete(this.root);
|
|
38
49
|
};
|
|
39
50
|
}
|
|
51
|
+
#withPublicationLock(operation) {
|
|
52
|
+
return Effect.uninterruptibleMask((restore) => Effect.promise(() => this.#acquirePublication()).pipe(Effect.flatMap((release) => restore(operation).pipe(Effect.ensuring(Effect.sync(release))))));
|
|
53
|
+
}
|
|
40
54
|
read() {
|
|
41
55
|
const root = this.root;
|
|
42
56
|
return Effect.gen(function* () {
|
|
43
|
-
const
|
|
44
|
-
|
|
57
|
+
const deployment = yield* readOrMigrateDeployment(root);
|
|
58
|
+
const activation = deployment.authoritative?.activation;
|
|
59
|
+
return activation?.version === 2 ? activation : undefined;
|
|
45
60
|
});
|
|
46
61
|
}
|
|
47
62
|
readPrevious() {
|
|
48
63
|
const root = this.root;
|
|
49
64
|
return Effect.gen(function* () {
|
|
50
65
|
const paths = yield* Path.Path;
|
|
51
|
-
return yield*
|
|
66
|
+
return yield* readOrMigrateDeployment(root).pipe(Effect.map((deployment) => {
|
|
67
|
+
const activation = deployment.previousAuthoritative?.activation;
|
|
68
|
+
return activation?.version === 2 ? activation : undefined;
|
|
69
|
+
}), Effect.catch(() => readLegacyActivation(paths.join(root, PREVIOUS_SNAPSHOT_FILE))));
|
|
52
70
|
});
|
|
53
71
|
}
|
|
72
|
+
readDeployment() {
|
|
73
|
+
return readOrMigrateDeployment(this.root);
|
|
74
|
+
}
|
|
54
75
|
publish(publication) {
|
|
55
|
-
return this.#withPublicationLock(this.#
|
|
76
|
+
return this.#withPublicationLock(this.#publishLegacy(publication));
|
|
56
77
|
}
|
|
57
|
-
/**
|
|
58
|
-
* Publish only if the active evidence generation still matches the caller's
|
|
59
|
-
* view. `undefined` means the caller expects no active publication.
|
|
60
|
-
*/
|
|
61
78
|
publishIfCurrent(publication, expectedEvidenceDigest) {
|
|
62
|
-
const root = this.root;
|
|
63
79
|
const store = this;
|
|
64
80
|
return this.#withPublicationLock(Effect.gen(function* () {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return yield* store.#
|
|
81
|
+
const current = yield* readOrMigrateDeployment(store.root);
|
|
82
|
+
const activation = current.authoritative?.activation;
|
|
83
|
+
const actual = activation?.version === 2 ? activation.evidenceDigest : undefined;
|
|
84
|
+
if (actual !== expectedEvidenceDigest)
|
|
85
|
+
return yield* Effect.fail(new RoutingActivationConflictError(expectedEvidenceDigest, actual));
|
|
86
|
+
return yield* store.#publishLegacy(publication);
|
|
71
87
|
}));
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
installAuthoritative(activation, expectedAuthoritativeRevisionDigest, expectedPreviousAuthoritativeRevisionDigest) {
|
|
90
|
+
const store = this;
|
|
91
|
+
return this.#withPublicationLock(Effect.gen(function* () {
|
|
92
|
+
yield* validateAnyActivation(activation, "routing activation is invalid");
|
|
93
|
+
const current = yield* readOrMigrateDeployment(store.root);
|
|
94
|
+
assertSlot("authoritative", expectedAuthoritativeRevisionDigest, current.authoritative?.activationRevisionDigest ?? null);
|
|
95
|
+
assertSlot("previousAuthoritative", expectedPreviousAuthoritativeRevisionDigest, current.previousAuthoritative?.activationRevisionDigest ?? null);
|
|
96
|
+
if (current.authoritative === null && current.previousAuthoritative !== null)
|
|
97
|
+
return yield* new RouteKitFailure({
|
|
98
|
+
message: "cannot install authority into an empty slot while a previous authoritative revision exists"
|
|
99
|
+
});
|
|
100
|
+
const state = {
|
|
101
|
+
version: 1,
|
|
102
|
+
stateRevision: current.stateRevision + 1,
|
|
103
|
+
authoritative: routingActivationRevision(activation),
|
|
104
|
+
previousAuthoritative: current.authoritative,
|
|
105
|
+
updatedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
|
|
106
|
+
};
|
|
107
|
+
yield* writeDeployment(store.root, state);
|
|
108
|
+
return state;
|
|
109
|
+
}));
|
|
75
110
|
}
|
|
76
|
-
|
|
77
|
-
const
|
|
111
|
+
rollbackAuthoritative(expectedAuthoritativeRevisionDigest, expectedPreviousAuthoritativeRevisionDigest) {
|
|
112
|
+
const store = this;
|
|
113
|
+
return this.#withPublicationLock(Effect.gen(function* () {
|
|
114
|
+
const current = yield* readOrMigrateDeployment(store.root);
|
|
115
|
+
assertSlot("authoritative", expectedAuthoritativeRevisionDigest, current.authoritative?.activationRevisionDigest ?? null);
|
|
116
|
+
assertSlot("previousAuthoritative", expectedPreviousAuthoritativeRevisionDigest, current.previousAuthoritative?.activationRevisionDigest ?? null);
|
|
117
|
+
if (current.authoritative === null || current.previousAuthoritative === null)
|
|
118
|
+
return yield* new RouteKitFailure({
|
|
119
|
+
message: "routing rollback requires both authoritative slots"
|
|
120
|
+
});
|
|
121
|
+
const state = {
|
|
122
|
+
version: 1,
|
|
123
|
+
stateRevision: current.stateRevision + 1,
|
|
124
|
+
authoritative: current.previousAuthoritative,
|
|
125
|
+
previousAuthoritative: current.authoritative,
|
|
126
|
+
updatedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
|
|
127
|
+
};
|
|
128
|
+
yield* writeDeployment(store.root, state);
|
|
129
|
+
return state;
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
#publishLegacy(publication) {
|
|
133
|
+
const store = this;
|
|
78
134
|
return Effect.gen(function* () {
|
|
79
135
|
const snapshot = {
|
|
80
136
|
version: COMPOSITIONAL_ROUTING_VERSION,
|
|
@@ -84,62 +140,136 @@ export class RoutingActivationStore {
|
|
|
84
140
|
const decoded = yield* Schema.decodeEffect(PublishedRoutingActivation)(snapshot).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
85
141
|
message: `published routing activation is invalid: ${String(cause)}`
|
|
86
142
|
})));
|
|
87
|
-
yield*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const paths = yield* Path.Path;
|
|
99
|
-
yield* fs.makeDirectory(root, { recursive: true, mode: 0o700 });
|
|
100
|
-
yield* fs.chmod(root, 0o700).pipe(Effect.ignore);
|
|
101
|
-
const path = paths.join(root, SNAPSHOT_FILE);
|
|
102
|
-
const previousPath = paths.join(root, PREVIOUS_SNAPSHOT_FILE);
|
|
103
|
-
const current = yield* readActivation(path);
|
|
104
|
-
if (current !== undefined) {
|
|
105
|
-
const previous = `${JSON.stringify(current, null, 2)}\n`;
|
|
106
|
-
assertBoundedSnapshot(previous);
|
|
107
|
-
yield* writeFileAtomicEffect(previousPath, previous, { mode: 0o600 });
|
|
108
|
-
}
|
|
109
|
-
yield* writeFileAtomicEffect(path, serialized, { mode: 0o600 });
|
|
143
|
+
yield* validateAnyActivation(decoded, "published routing activation is invalid");
|
|
144
|
+
const current = yield* readOrMigrateDeployment(store.root);
|
|
145
|
+
const state = {
|
|
146
|
+
version: 1,
|
|
147
|
+
stateRevision: current.stateRevision + 1,
|
|
148
|
+
authoritative: routingActivationRevision(decoded),
|
|
149
|
+
previousAuthoritative: current.authoritative,
|
|
150
|
+
updatedAt: decoded.generatedAt
|
|
151
|
+
};
|
|
152
|
+
yield* writeLegacyCompatibilitySnapshots(store.root, decoded, current.authoritative);
|
|
153
|
+
yield* writeDeployment(store.root, state);
|
|
110
154
|
return decoded;
|
|
111
155
|
});
|
|
112
156
|
}
|
|
113
157
|
}
|
|
158
|
+
function writeLegacyCompatibilitySnapshots(root, current, displaced) {
|
|
159
|
+
return Effect.gen(function* () {
|
|
160
|
+
const fs = yield* FileSystem.FileSystem;
|
|
161
|
+
const paths = yield* Path.Path;
|
|
162
|
+
yield* fs.makeDirectory(root, { recursive: true, mode: 0o700 });
|
|
163
|
+
yield* fs.chmod(root, 0o700).pipe(Effect.ignore);
|
|
164
|
+
if (displaced?.activation.version === 2) {
|
|
165
|
+
const previous = `${JSON.stringify(displaced.activation, null, 2)}\n`;
|
|
166
|
+
assertBoundedSnapshot(previous);
|
|
167
|
+
yield* writeFileAtomicEffect(paths.join(root, PREVIOUS_SNAPSHOT_FILE), previous, {
|
|
168
|
+
mode: 0o600
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const serialized = `${JSON.stringify(current, null, 2)}\n`;
|
|
172
|
+
assertBoundedSnapshot(serialized);
|
|
173
|
+
yield* writeFileAtomicEffect(paths.join(root, SNAPSHOT_FILE), serialized, { mode: 0o600 });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function assertSlot(slot, expected, actual) {
|
|
177
|
+
if (expected !== actual)
|
|
178
|
+
throw new RoutingDeploymentConflictError(slot, expected, actual);
|
|
179
|
+
}
|
|
114
180
|
function detailOf(cause) {
|
|
115
181
|
return cause instanceof Error ? cause.message : String(cause);
|
|
116
182
|
}
|
|
117
183
|
function assertBoundedSnapshot(raw) {
|
|
118
|
-
if (Buffer.byteLength(raw, "utf8") > ROUTING_ACTIVATION_MAX_BYTES)
|
|
184
|
+
if (Buffer.byteLength(raw, "utf8") > ROUTING_ACTIVATION_MAX_BYTES)
|
|
119
185
|
throw new RouteKitFailure({
|
|
120
186
|
message: `published routing activation exceeds the ${String(ROUTING_ACTIVATION_MAX_BYTES)} byte limit`
|
|
121
187
|
});
|
|
122
|
-
}
|
|
123
188
|
}
|
|
124
|
-
function
|
|
189
|
+
function validateAnyActivation(activation, prefix) {
|
|
190
|
+
return Effect.try({
|
|
191
|
+
try: () => {
|
|
192
|
+
if (activation.version === 2)
|
|
193
|
+
assertPublishedRoutingActivation(activation);
|
|
194
|
+
else
|
|
195
|
+
assertPublishedRoutingActivationV3(activation);
|
|
196
|
+
activationRevisionDigest(activation);
|
|
197
|
+
},
|
|
198
|
+
catch: (cause) => new RouteKitFailure({ message: `${prefix}: ${detailOf(cause)}` })
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function emptyDeployment(updatedAt) {
|
|
202
|
+
return {
|
|
203
|
+
version: 1,
|
|
204
|
+
stateRevision: 0,
|
|
205
|
+
authoritative: null,
|
|
206
|
+
previousAuthoritative: null,
|
|
207
|
+
updatedAt
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function readOrMigrateDeployment(root) {
|
|
125
211
|
return Effect.gen(function* () {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
212
|
+
const paths = yield* Path.Path;
|
|
213
|
+
const existing = yield* readDeployment(paths.join(root, DEPLOYMENT_FILE));
|
|
214
|
+
if (existing !== undefined)
|
|
215
|
+
return existing;
|
|
216
|
+
const current = yield* readLegacyActivation(paths.join(root, SNAPSHOT_FILE));
|
|
217
|
+
const previous = yield* readLegacyActivation(paths.join(root, PREVIOUS_SNAPSHOT_FILE));
|
|
218
|
+
if (current === undefined && previous === undefined)
|
|
219
|
+
return emptyDeployment("1970-01-01T00:00:00.000Z");
|
|
220
|
+
if (current === undefined && previous !== undefined)
|
|
131
221
|
return yield* new RouteKitFailure({
|
|
132
|
-
message:
|
|
222
|
+
message: "legacy routing deployment is corrupt: previous activation exists without current activation"
|
|
133
223
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
224
|
+
const state = {
|
|
225
|
+
version: 1,
|
|
226
|
+
stateRevision: 0,
|
|
227
|
+
authoritative: routingActivationRevision(current),
|
|
228
|
+
previousAuthoritative: previous === undefined ? null : routingActivationRevision(previous),
|
|
229
|
+
updatedAt: current.generatedAt
|
|
230
|
+
};
|
|
231
|
+
yield* writeDeployment(root, state);
|
|
232
|
+
return state;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function writeDeployment(root, state) {
|
|
236
|
+
return Effect.gen(function* () {
|
|
237
|
+
yield* Effect.try({
|
|
238
|
+
try: () => assertRoutingDeploymentStateV1(state),
|
|
239
|
+
catch: (cause) => new RouteKitFailure({ message: `routing deployment is invalid: ${detailOf(cause)}` })
|
|
240
|
+
});
|
|
241
|
+
const decoded = yield* Schema.decodeEffect(RoutingDeploymentStateV1)(state).pipe(Effect.mapError((cause) => new RouteKitFailure({ message: `routing deployment is invalid: ${String(cause)}` })));
|
|
242
|
+
const serialized = `${JSON.stringify(decoded, null, 2)}\n`;
|
|
243
|
+
assertBoundedSnapshot(serialized);
|
|
244
|
+
const fs = yield* FileSystem.FileSystem;
|
|
245
|
+
const paths = yield* Path.Path;
|
|
246
|
+
yield* fs.makeDirectory(root, { recursive: true, mode: 0o700 });
|
|
247
|
+
yield* fs.chmod(root, 0o700).pipe(Effect.ignore);
|
|
248
|
+
yield* writeFileAtomicEffect(paths.join(root, DEPLOYMENT_FILE), serialized, { mode: 0o600 });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function readDeployment(path) {
|
|
252
|
+
return Effect.gen(function* () {
|
|
253
|
+
const json = yield* readJsonDocument(path, "routing deployment is corrupt");
|
|
254
|
+
if (json === undefined)
|
|
255
|
+
return undefined;
|
|
256
|
+
const decoded = yield* Schema.decodeUnknownEffect(RoutingDeploymentStateV1)(json).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
257
|
+
message: `routing deployment is corrupt: ${String(cause)}`
|
|
258
|
+
})));
|
|
259
|
+
yield* Effect.try({
|
|
260
|
+
try: () => assertRoutingDeploymentStateV1(decoded),
|
|
139
261
|
catch: (cause) => new RouteKitFailure({
|
|
140
|
-
message: `
|
|
262
|
+
message: `routing deployment is corrupt: ${detailOf(cause)}`
|
|
141
263
|
})
|
|
142
264
|
});
|
|
265
|
+
return decoded;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function readLegacyActivation(path) {
|
|
269
|
+
return Effect.gen(function* () {
|
|
270
|
+
const json = yield* readJsonDocument(path, "published routing activation is corrupt");
|
|
271
|
+
if (json === undefined)
|
|
272
|
+
return undefined;
|
|
143
273
|
const decoded = yield* Schema.decodeUnknownEffect(PublishedRoutingActivation)(json).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
144
274
|
message: `published routing activation is corrupt: ${String(cause)}`
|
|
145
275
|
})));
|
|
@@ -152,6 +282,25 @@ function readActivation(path) {
|
|
|
152
282
|
return decoded;
|
|
153
283
|
});
|
|
154
284
|
}
|
|
285
|
+
function readJsonDocument(path, prefix) {
|
|
286
|
+
return Effect.gen(function* () {
|
|
287
|
+
const fs = yield* FileSystem.FileSystem;
|
|
288
|
+
if (!(yield* fs.exists(path)))
|
|
289
|
+
return undefined;
|
|
290
|
+
const info = yield* fs.stat(path);
|
|
291
|
+
if (Number(info.size) > ROUTING_ACTIVATION_MAX_BYTES)
|
|
292
|
+
return yield* new RouteKitFailure({
|
|
293
|
+
message: `published routing activation exceeds the ${String(ROUTING_ACTIVATION_MAX_BYTES)} byte limit`
|
|
294
|
+
});
|
|
295
|
+
const raw = yield* fs.readFileString(path);
|
|
296
|
+
assertBoundedSnapshot(raw);
|
|
297
|
+
const json = yield* Effect.try({
|
|
298
|
+
try: () => JSON.parse(raw),
|
|
299
|
+
catch: (cause) => new RouteKitFailure({ message: `${prefix}: ${detailOf(cause)}` })
|
|
300
|
+
});
|
|
301
|
+
return json;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
155
304
|
export function makeRoutingActivationStore(root) {
|
|
156
305
|
return new RoutingActivationStore(root);
|
|
157
306
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
import type { FileSystem, Path } from "effect";
|
|
3
|
+
import type { ExperimentArtifactReference, ExperimentArtifactVisibility } from "../../experiment-artifacts.js";
|
|
4
|
+
export interface ExperimentArtifactStoreShape {
|
|
5
|
+
readonly write: (runId: string, name: string, value: unknown, visibility?: ExperimentArtifactVisibility) => Effect.Effect<ExperimentArtifactReference, Error, FileSystem.FileSystem | Path.Path>;
|
|
6
|
+
readonly read: (runId: string, name: string, visibility?: ExperimentArtifactVisibility) => Effect.Effect<string | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
7
|
+
}
|
|
8
|
+
declare const ExperimentArtifactStore_base: Context.ServiceClass<ExperimentArtifactStore, "routekit/ExperimentArtifactStore", ExperimentArtifactStoreShape>;
|
|
9
|
+
/** @effect-expect-leaking FileSystem | Path */
|
|
10
|
+
export declare class ExperimentArtifactStore extends ExperimentArtifactStore_base {
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
+
import { activationRevisionDigest } from "@velum-labs/routekit-eval-contracts";
|
|
6
7
|
import { runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
|
|
7
|
-
import { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError } from "../routing-activation.js";
|
|
8
|
+
import { makeRoutingActivationStore, ROUTING_ACTIVATION_MAX_BYTES, RoutingActivationConflictError, RoutingDeploymentConflictError } from "../routing-activation.js";
|
|
8
9
|
const dimensions = [
|
|
9
10
|
"gateway-protocol",
|
|
10
11
|
"eval-routing",
|
|
@@ -131,3 +132,50 @@ test("concurrent routing activation publishers serialize complete generations",
|
|
|
131
132
|
rmSync(root, { recursive: true, force: true });
|
|
132
133
|
}
|
|
133
134
|
});
|
|
135
|
+
test("legacy V2 files migrate idempotently into cross-version deployment state", async () => {
|
|
136
|
+
const root = mkdtempSync(join(tmpdir(), "routekit-routing-deployment-migrate-"));
|
|
137
|
+
try {
|
|
138
|
+
const current = {
|
|
139
|
+
version: 2,
|
|
140
|
+
generatedAt: "2026-08-23T00:00:00.000Z",
|
|
141
|
+
...publication("current")
|
|
142
|
+
};
|
|
143
|
+
const previous = {
|
|
144
|
+
version: 2,
|
|
145
|
+
generatedAt: "2026-08-22T00:00:00.000Z",
|
|
146
|
+
...publication("previous")
|
|
147
|
+
};
|
|
148
|
+
writeFileSync(join(root, "published-routing.json"), `${JSON.stringify(current)}\n`);
|
|
149
|
+
writeFileSync(join(root, "published-routing.previous.json"), `${JSON.stringify(previous)}\n`);
|
|
150
|
+
const store = makeRoutingActivationStore(root);
|
|
151
|
+
const first = await runRouteKitEffect(store.readDeployment());
|
|
152
|
+
const second = await runRouteKitEffect(store.readDeployment());
|
|
153
|
+
assert.deepEqual(second, first);
|
|
154
|
+
assert.equal(first.authoritative?.activationRevisionDigest, activationRevisionDigest(current));
|
|
155
|
+
assert.equal(first.previousAuthoritative?.activationRevisionDigest, activationRevisionDigest(previous));
|
|
156
|
+
assert.equal(statSync(join(root, "routing-deployment.v1.json")).mode & 0o777, 0o600);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
rmSync(root, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test("V3 authority installation and rollback compare and swap both exact slots", async () => {
|
|
163
|
+
const root = mkdtempSync(join(tmpdir(), "routekit-routing-deployment-rollback-"));
|
|
164
|
+
try {
|
|
165
|
+
const store = makeRoutingActivationStore(root);
|
|
166
|
+
const v2 = await runRouteKitEffect(store.publish(publication("v2")));
|
|
167
|
+
const v2Revision = activationRevisionDigest(v2);
|
|
168
|
+
const v3 = JSON.parse(readFileSync(new URL("../../../../test/fixtures/routing-v3/examples/published-routing-activation-v3.example.json", import.meta.url), "utf8"));
|
|
169
|
+
const installed = await runRouteKitEffect(store.installAuthoritative(v3, v2Revision, null));
|
|
170
|
+
assert.equal(installed.authoritative?.activation.version, 3);
|
|
171
|
+
assert.equal(installed.previousAuthoritative?.activationRevisionDigest, v2Revision);
|
|
172
|
+
await assert.rejects(runRouteKitEffect(store.installAuthoritative(v3, v2Revision, null)), (error) => error instanceof RoutingDeploymentConflictError && error.slot === "authoritative");
|
|
173
|
+
const rolledBack = await runRouteKitEffect(store.rollbackAuthoritative(v3.activationDigest, v2Revision));
|
|
174
|
+
assert.equal(rolledBack.authoritative?.activation.version, 2);
|
|
175
|
+
assert.equal(rolledBack.previousAuthoritative?.activation.version, 3);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
chmodSync(root, 0o700);
|
|
179
|
+
rmSync(root, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-eval-store",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"effect": "4.0.0-rc.108",
|
|
34
|
-
"@velum-labs/routekit-eval-contracts": "1.0
|
|
35
|
-
"@velum-labs/routekit-runtime": "1.0
|
|
34
|
+
"@velum-labs/routekit-eval-contracts": "1.1.0",
|
|
35
|
+
"@velum-labs/routekit-runtime": "1.1.0"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"routekit",
|