@velum-labs/routekit-eval-store 1.0.25 → 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.
@@ -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";
@@ -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<PublishedRoutingActivation, "version" | "generatedAt">;
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<PublishedRoutingActivation | undefined, Error, FileSystem.FileSystem | Path.Path>;
15
- readPrevious(): Effect.Effect<PublishedRoutingActivation | undefined, Error, FileSystem.FileSystem | Path.Path>;
16
- publish(publication: RoutingActivationPublication): Effect.Effect<PublishedRoutingActivation, Error, FileSystem.FileSystem | Path.Path>;
17
- /**
18
- * Publish only if the active evidence generation still matches the caller's
19
- * view. `undefined` means the caller expects no active publication.
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 paths = yield* Path.Path;
44
- return yield* readActivation(paths.join(root, SNAPSHOT_FILE));
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* readActivation(paths.join(root, PREVIOUS_SNAPSHOT_FILE));
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.#publish(publication));
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 paths = yield* Path.Path;
66
- const current = yield* readActivation(paths.join(root, SNAPSHOT_FILE));
67
- if (current?.evidenceDigest !== expectedEvidenceDigest) {
68
- return yield* Effect.fail(new RoutingActivationConflictError(expectedEvidenceDigest, current?.evidenceDigest));
69
- }
70
- return yield* store.#publish(publication);
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
- #withPublicationLock(operation) {
74
- return Effect.uninterruptibleMask((restore) => Effect.promise(() => this.#acquirePublication()).pipe(Effect.flatMap((release) => restore(operation).pipe(Effect.ensuring(Effect.sync(release))))));
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
- #publish(publication) {
77
- const root = this.root;
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* Effect.try({
88
- try: () => assertPublishedRoutingActivation(decoded),
89
- catch: (cause) => new RouteKitFailure({
90
- message: `published routing activation is invalid: ${detailOf(cause)}`
91
- })
92
- });
93
- // Check the final representation before rotating the current known-good
94
- // document. An oversized publication must leave both generations intact.
95
- const serialized = `${JSON.stringify(decoded, null, 2)}\n`;
96
- assertBoundedSnapshot(serialized);
97
- const fs = yield* FileSystem.FileSystem;
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 readActivation(path) {
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 fs = yield* FileSystem.FileSystem;
127
- if (!(yield* fs.exists(path)))
128
- return undefined;
129
- const info = yield* fs.stat(path);
130
- if (Number(info.size) > ROUTING_ACTIVATION_MAX_BYTES) {
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: `published routing activation exceeds the ${String(ROUTING_ACTIVATION_MAX_BYTES)} byte limit`
222
+ message: "legacy routing deployment is corrupt: previous activation exists without current activation"
133
223
  });
134
- }
135
- const raw = yield* fs.readFileString(path);
136
- assertBoundedSnapshot(raw);
137
- const json = yield* Effect.try({
138
- try: () => JSON.parse(raw),
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: `published routing activation is corrupt: ${detailOf(cause)}`
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 {};
@@ -0,0 +1,4 @@
1
+ import { Context } from "effect";
2
+ /** @effect-expect-leaking FileSystem | Path */
3
+ export class ExperimentArtifactStore extends Context.Service()("routekit/ExperimentArtifactStore") {
4
+ }
@@ -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.25",
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.25",
35
- "@velum-labs/routekit-runtime": "1.0.25"
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",