@velum-labs/routekit-eval-store 1.2.0 → 1.3.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.
|
@@ -20,9 +20,10 @@ export declare class RoutingActivationStore {
|
|
|
20
20
|
read(): Effect.Effect<PublishedRoutingActivationType | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
21
21
|
readPrevious(): Effect.Effect<PublishedRoutingActivationType | undefined, Error, FileSystem.FileSystem | Path.Path>;
|
|
22
22
|
readDeployment(): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
23
|
+
readActivation(activationPath: string): Effect.Effect<AnyPublishedRoutingActivationType, Error, FileSystem.FileSystem | Path.Path>;
|
|
23
24
|
publish(publication: RoutingActivationPublication): Effect.Effect<PublishedRoutingActivationType, Error, FileSystem.FileSystem | Path.Path>;
|
|
24
25
|
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
|
+
installAuthoritative(activation: AnyPublishedRoutingActivationType, activationPath: string, expectedAuthoritativeRevisionDigest: string | null, expectedPreviousAuthoritativeRevisionDigest: string | null): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
26
27
|
rollbackAuthoritative(expectedAuthoritativeRevisionDigest: string, expectedPreviousAuthoritativeRevisionDigest: string): Effect.Effect<RoutingDeploymentStateV1Type, Error, FileSystem.FileSystem | Path.Path>;
|
|
27
28
|
}
|
|
28
29
|
export declare function makeRoutingActivationStore(root: string): RoutingActivationStore;
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import { activationRevisionDigest, assertPublishedRoutingActivation, assertPublishedRoutingActivationV3, assertRoutingDeploymentStateV1, COMPOSITIONAL_ROUTING_VERSION, PublishedRoutingActivation, RoutingDeploymentStateV1, routingActivationRevision } from "@velum-labs/routekit-eval-contracts";
|
|
1
|
+
import { activationRevisionDigest, AnyPublishedRoutingActivation, 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
6
|
const DEPLOYMENT_FILE = "routing-deployment.v1.json";
|
|
7
|
+
const POINTER_DEPLOYMENT_FILE = "routing-deployment.v2.json";
|
|
7
8
|
export const ROUTING_ACTIVATION_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
9
|
const publicationTails = new Map();
|
|
10
|
+
const RoutingActivationReference = Schema.Struct({
|
|
11
|
+
activationRevisionDigest: Schema.String,
|
|
12
|
+
activationPath: Schema.String
|
|
13
|
+
});
|
|
14
|
+
const RoutingPointerDeployment = Schema.Struct({
|
|
15
|
+
version: Schema.Literal(2),
|
|
16
|
+
stateRevision: Schema.Finite,
|
|
17
|
+
authoritative: Schema.NullOr(RoutingActivationReference),
|
|
18
|
+
previousAuthoritative: Schema.NullOr(RoutingActivationReference),
|
|
19
|
+
updatedAt: Schema.String
|
|
20
|
+
});
|
|
9
21
|
export class RoutingActivationConflictError extends Error {
|
|
10
22
|
expectedEvidenceDigest;
|
|
11
23
|
actualEvidenceDigest;
|
|
@@ -54,7 +66,7 @@ export class RoutingActivationStore {
|
|
|
54
66
|
read() {
|
|
55
67
|
const root = this.root;
|
|
56
68
|
return Effect.gen(function* () {
|
|
57
|
-
const deployment = yield*
|
|
69
|
+
const deployment = yield* readResolvedDeployment(root);
|
|
58
70
|
const activation = deployment.authoritative?.activation;
|
|
59
71
|
return activation?.version === 2 ? activation : undefined;
|
|
60
72
|
});
|
|
@@ -63,14 +75,17 @@ export class RoutingActivationStore {
|
|
|
63
75
|
const root = this.root;
|
|
64
76
|
return Effect.gen(function* () {
|
|
65
77
|
const paths = yield* Path.Path;
|
|
66
|
-
return yield*
|
|
78
|
+
return yield* readResolvedDeployment(root).pipe(Effect.map((deployment) => {
|
|
67
79
|
const activation = deployment.previousAuthoritative?.activation;
|
|
68
80
|
return activation?.version === 2 ? activation : undefined;
|
|
69
81
|
}), Effect.catch(() => readLegacyActivation(paths.join(root, PREVIOUS_SNAPSHOT_FILE))));
|
|
70
82
|
});
|
|
71
83
|
}
|
|
72
84
|
readDeployment() {
|
|
73
|
-
return
|
|
85
|
+
return readResolvedDeployment(this.root);
|
|
86
|
+
}
|
|
87
|
+
readActivation(activationPath) {
|
|
88
|
+
return readPointedActivation(activationPath);
|
|
74
89
|
}
|
|
75
90
|
publish(publication) {
|
|
76
91
|
return this.#withPublicationLock(this.#publishLegacy(publication));
|
|
@@ -78,6 +93,10 @@ export class RoutingActivationStore {
|
|
|
78
93
|
publishIfCurrent(publication, expectedEvidenceDigest) {
|
|
79
94
|
const store = this;
|
|
80
95
|
return this.#withPublicationLock(Effect.gen(function* () {
|
|
96
|
+
if ((yield* readPointerDeployment(store.root)) !== undefined)
|
|
97
|
+
return yield* new RouteKitFailure({
|
|
98
|
+
message: "legacy routing publication cannot replace a pointer-backed deployment"
|
|
99
|
+
});
|
|
81
100
|
const current = yield* readOrMigrateDeployment(store.root);
|
|
82
101
|
const activation = current.authoritative?.activation;
|
|
83
102
|
const actual = activation?.version === 2 ? activation.evidenceDigest : undefined;
|
|
@@ -86,52 +105,90 @@ export class RoutingActivationStore {
|
|
|
86
105
|
return yield* store.#publishLegacy(publication);
|
|
87
106
|
}));
|
|
88
107
|
}
|
|
89
|
-
installAuthoritative(activation, expectedAuthoritativeRevisionDigest, expectedPreviousAuthoritativeRevisionDigest) {
|
|
108
|
+
installAuthoritative(activation, activationPath, expectedAuthoritativeRevisionDigest, expectedPreviousAuthoritativeRevisionDigest) {
|
|
90
109
|
const store = this;
|
|
91
110
|
return this.#withPublicationLock(Effect.gen(function* () {
|
|
92
111
|
yield* validateAnyActivation(activation, "routing activation is invalid");
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
const currentPointers = yield* readPointerDeployment(store.root);
|
|
113
|
+
const current = currentPointers === undefined
|
|
114
|
+
? yield* readOrMigrateDeployment(store.root)
|
|
115
|
+
: undefined;
|
|
116
|
+
assertSlot("authoritative", expectedAuthoritativeRevisionDigest, currentPointers?.authoritative?.activationRevisionDigest ??
|
|
117
|
+
current?.authoritative?.activationRevisionDigest ??
|
|
118
|
+
null);
|
|
119
|
+
assertSlot("previousAuthoritative", expectedPreviousAuthoritativeRevisionDigest, currentPointers?.previousAuthoritative?.activationRevisionDigest ??
|
|
120
|
+
current?.previousAuthoritative?.activationRevisionDigest ??
|
|
121
|
+
null);
|
|
122
|
+
const currentAuthoritative = currentPointers === undefined
|
|
123
|
+
? current?.authoritative ?? null
|
|
124
|
+
: yield* resolvePointerRevision(currentPointers.authoritative);
|
|
125
|
+
if (currentAuthoritative === null &&
|
|
126
|
+
(currentPointers?.previousAuthoritative ?? current?.previousAuthoritative ?? null) !== null)
|
|
97
127
|
return yield* new RouteKitFailure({
|
|
98
128
|
message: "cannot install authority into an empty slot while a previous authoritative revision exists"
|
|
99
129
|
});
|
|
130
|
+
const activationReference = yield* referenceForActivation(activationPath, activation);
|
|
131
|
+
const authoritativeReference = (currentAuthoritative === null
|
|
132
|
+
? null
|
|
133
|
+
: yield* materializeDisplacedReference(activationReference.activationPath, currentAuthoritative.activation));
|
|
100
134
|
const state = {
|
|
101
|
-
version:
|
|
102
|
-
stateRevision: current
|
|
103
|
-
authoritative:
|
|
104
|
-
previousAuthoritative:
|
|
135
|
+
version: 2,
|
|
136
|
+
stateRevision: (currentPointers?.stateRevision ?? current?.stateRevision ?? 0) + 1,
|
|
137
|
+
authoritative: activationReference,
|
|
138
|
+
previousAuthoritative: authoritativeReference,
|
|
105
139
|
updatedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
|
|
106
140
|
};
|
|
107
|
-
yield*
|
|
108
|
-
|
|
141
|
+
yield* writePointerDeployment(store.root, state);
|
|
142
|
+
yield* removeLegacyDeploymentBodies(store.root);
|
|
143
|
+
return yield* resolvePointerDeployment(state);
|
|
109
144
|
}));
|
|
110
145
|
}
|
|
111
146
|
rollbackAuthoritative(expectedAuthoritativeRevisionDigest, expectedPreviousAuthoritativeRevisionDigest) {
|
|
112
147
|
const store = this;
|
|
113
148
|
return this.#withPublicationLock(Effect.gen(function* () {
|
|
114
|
-
const
|
|
149
|
+
const currentPointers = yield* readPointerDeployment(store.root);
|
|
150
|
+
const current = currentPointers === undefined
|
|
151
|
+
? yield* readOrMigrateDeployment(store.root)
|
|
152
|
+
: yield* resolvePointerDeployment(currentPointers);
|
|
115
153
|
assertSlot("authoritative", expectedAuthoritativeRevisionDigest, current.authoritative?.activationRevisionDigest ?? null);
|
|
116
154
|
assertSlot("previousAuthoritative", expectedPreviousAuthoritativeRevisionDigest, current.previousAuthoritative?.activationRevisionDigest ?? null);
|
|
117
155
|
if (current.authoritative === null || current.previousAuthoritative === null)
|
|
118
156
|
return yield* new RouteKitFailure({
|
|
119
157
|
message: "routing rollback requires both authoritative slots"
|
|
120
158
|
});
|
|
121
|
-
|
|
122
|
-
|
|
159
|
+
if (currentPointers === undefined) {
|
|
160
|
+
const state = {
|
|
161
|
+
version: 1,
|
|
162
|
+
stateRevision: current.stateRevision + 1,
|
|
163
|
+
authoritative: current.previousAuthoritative,
|
|
164
|
+
previousAuthoritative: current.authoritative,
|
|
165
|
+
updatedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
|
|
166
|
+
};
|
|
167
|
+
yield* writeDeployment(store.root, state);
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
if (currentPointers.authoritative === null || currentPointers.previousAuthoritative === null)
|
|
171
|
+
return yield* new RouteKitFailure({
|
|
172
|
+
message: "routing rollback requires both pointer-backed authoritative slots"
|
|
173
|
+
});
|
|
174
|
+
const pointerState = {
|
|
175
|
+
version: 2,
|
|
123
176
|
stateRevision: current.stateRevision + 1,
|
|
124
|
-
authoritative:
|
|
125
|
-
previousAuthoritative:
|
|
177
|
+
authoritative: currentPointers.previousAuthoritative,
|
|
178
|
+
previousAuthoritative: currentPointers.authoritative,
|
|
126
179
|
updatedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
|
|
127
180
|
};
|
|
128
|
-
yield*
|
|
129
|
-
return
|
|
181
|
+
yield* writePointerDeployment(store.root, pointerState);
|
|
182
|
+
return yield* resolvePointerDeployment(pointerState);
|
|
130
183
|
}));
|
|
131
184
|
}
|
|
132
185
|
#publishLegacy(publication) {
|
|
133
186
|
const store = this;
|
|
134
187
|
return Effect.gen(function* () {
|
|
188
|
+
if ((yield* readPointerDeployment(store.root)) !== undefined)
|
|
189
|
+
return yield* new RouteKitFailure({
|
|
190
|
+
message: "legacy routing publication cannot replace a pointer-backed deployment"
|
|
191
|
+
});
|
|
135
192
|
const snapshot = {
|
|
136
193
|
version: COMPOSITIONAL_ROUTING_VERSION,
|
|
137
194
|
generatedAt: new Date(yield* Clock.currentTimeMillis).toISOString(),
|
|
@@ -155,6 +212,175 @@ export class RoutingActivationStore {
|
|
|
155
212
|
});
|
|
156
213
|
}
|
|
157
214
|
}
|
|
215
|
+
function referenceForActivation(activationPath, expected) {
|
|
216
|
+
return Effect.gen(function* () {
|
|
217
|
+
const fs = yield* FileSystem.FileSystem;
|
|
218
|
+
const paths = yield* Path.Path;
|
|
219
|
+
if (!paths.isAbsolute(activationPath))
|
|
220
|
+
return yield* new RouteKitFailure({
|
|
221
|
+
message: "routing activation pointer must be an absolute path"
|
|
222
|
+
});
|
|
223
|
+
const canonicalPath = yield* fs.realPath(activationPath).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
224
|
+
message: `failed to resolve routing activation pointer: ${detailOf(cause)}`
|
|
225
|
+
})));
|
|
226
|
+
const activation = yield* readAnyActivation(canonicalPath, "routing activation pointer");
|
|
227
|
+
const expectedDigest = activationRevisionDigest(expected);
|
|
228
|
+
const actualDigest = activationRevisionDigest(activation);
|
|
229
|
+
if (actualDigest !== expectedDigest)
|
|
230
|
+
return yield* new RouteKitFailure({
|
|
231
|
+
message: "routing activation pointer does not match the validated activation"
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
activationRevisionDigest: expectedDigest,
|
|
235
|
+
activationPath: canonicalPath
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function readPointedActivation(activationPath) {
|
|
240
|
+
return Effect.gen(function* () {
|
|
241
|
+
const fs = yield* FileSystem.FileSystem;
|
|
242
|
+
const paths = yield* Path.Path;
|
|
243
|
+
if (!paths.isAbsolute(activationPath))
|
|
244
|
+
return yield* new RouteKitFailure({
|
|
245
|
+
message: "routing activation pointer must be an absolute path"
|
|
246
|
+
});
|
|
247
|
+
const canonicalPath = yield* fs.realPath(activationPath).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
248
|
+
message: `failed to resolve routing activation pointer: ${detailOf(cause)}`
|
|
249
|
+
})));
|
|
250
|
+
return yield* readAnyActivation(canonicalPath, "routing activation pointer");
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function materializeDisplacedReference(activationPath, activation) {
|
|
254
|
+
return Effect.gen(function* () {
|
|
255
|
+
const paths = yield* Path.Path;
|
|
256
|
+
if (!paths.isAbsolute(activationPath))
|
|
257
|
+
return yield* new RouteKitFailure({
|
|
258
|
+
message: "routing activation pointer must be an absolute path"
|
|
259
|
+
});
|
|
260
|
+
const revisionDigest = activationRevisionDigest(activation);
|
|
261
|
+
const destination = paths.join(paths.dirname(activationPath), `${revisionDigest}.json`);
|
|
262
|
+
const serialized = `${JSON.stringify(activation, null, 2)}\n`;
|
|
263
|
+
assertBoundedSnapshot(serialized);
|
|
264
|
+
yield* writeFileAtomicEffect(destination, serialized, { mode: 0o600 });
|
|
265
|
+
return {
|
|
266
|
+
activationRevisionDigest: revisionDigest,
|
|
267
|
+
activationPath: destination
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function resolvePointerDeployment(state) {
|
|
272
|
+
return Effect.gen(function* () {
|
|
273
|
+
const authoritative = yield* resolvePointerRevision(state.authoritative);
|
|
274
|
+
const previousAuthoritative = yield* resolvePointerRevision(state.previousAuthoritative);
|
|
275
|
+
const resolved = {
|
|
276
|
+
version: 1,
|
|
277
|
+
stateRevision: state.stateRevision,
|
|
278
|
+
authoritative,
|
|
279
|
+
previousAuthoritative,
|
|
280
|
+
updatedAt: state.updatedAt
|
|
281
|
+
};
|
|
282
|
+
yield* Effect.try({
|
|
283
|
+
try: () => assertRoutingDeploymentStateV1(resolved),
|
|
284
|
+
catch: (cause) => new RouteKitFailure({
|
|
285
|
+
message: `routing pointer deployment is invalid: ${detailOf(cause)}`
|
|
286
|
+
})
|
|
287
|
+
});
|
|
288
|
+
return resolved;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function resolvePointerRevision(reference) {
|
|
292
|
+
return reference === null
|
|
293
|
+
? Effect.succeed(null)
|
|
294
|
+
: readAnyActivation(reference.activationPath, "routing activation pointer").pipe(Effect.flatMap((activation) => {
|
|
295
|
+
const actual = activationRevisionDigest(activation);
|
|
296
|
+
return actual === reference.activationRevisionDigest
|
|
297
|
+
? Effect.succeed({
|
|
298
|
+
activationRevisionDigest: actual,
|
|
299
|
+
activation
|
|
300
|
+
})
|
|
301
|
+
: Effect.fail(new RouteKitFailure({
|
|
302
|
+
message: "routing activation pointer digest does not match its definition"
|
|
303
|
+
}));
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
function readPointerDeployment(root) {
|
|
307
|
+
return Effect.gen(function* () {
|
|
308
|
+
const paths = yield* Path.Path;
|
|
309
|
+
const path = paths.join(root, POINTER_DEPLOYMENT_FILE);
|
|
310
|
+
const json = yield* readJsonDocument(path, "routing pointer deployment is corrupt");
|
|
311
|
+
if (json === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
const decoded = yield* Schema.decodeUnknownEffect(RoutingPointerDeployment)(json).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
314
|
+
message: `routing pointer deployment is corrupt: ${String(cause)}`
|
|
315
|
+
})));
|
|
316
|
+
if (!Number.isSafeInteger(decoded.stateRevision) ||
|
|
317
|
+
decoded.stateRevision < 0 ||
|
|
318
|
+
!validActivationReference(decoded.authoritative, paths) ||
|
|
319
|
+
!validActivationReference(decoded.previousAuthoritative, paths) ||
|
|
320
|
+
(decoded.authoritative !== null &&
|
|
321
|
+
decoded.authoritative.activationRevisionDigest ===
|
|
322
|
+
decoded.previousAuthoritative?.activationRevisionDigest))
|
|
323
|
+
return yield* new RouteKitFailure({
|
|
324
|
+
message: "routing pointer deployment is invalid"
|
|
325
|
+
});
|
|
326
|
+
return decoded;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function validActivationReference(reference, paths) {
|
|
330
|
+
return (reference === null ||
|
|
331
|
+
(reference.activationRevisionDigest.length > 0 &&
|
|
332
|
+
reference.activationRevisionDigest === reference.activationRevisionDigest.trim() &&
|
|
333
|
+
paths.isAbsolute(reference.activationPath)));
|
|
334
|
+
}
|
|
335
|
+
function writePointerDeployment(root, state) {
|
|
336
|
+
return Effect.gen(function* () {
|
|
337
|
+
const decoded = yield* Schema.decodeEffect(RoutingPointerDeployment)(state).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
338
|
+
message: `routing pointer deployment is invalid: ${String(cause)}`
|
|
339
|
+
})));
|
|
340
|
+
const paths = yield* Path.Path;
|
|
341
|
+
if (!Number.isSafeInteger(decoded.stateRevision) ||
|
|
342
|
+
decoded.stateRevision < 0 ||
|
|
343
|
+
!validActivationReference(decoded.authoritative, paths) ||
|
|
344
|
+
!validActivationReference(decoded.previousAuthoritative, paths) ||
|
|
345
|
+
(decoded.authoritative !== null &&
|
|
346
|
+
decoded.authoritative.activationRevisionDigest ===
|
|
347
|
+
decoded.previousAuthoritative?.activationRevisionDigest))
|
|
348
|
+
return yield* new RouteKitFailure({
|
|
349
|
+
message: "routing pointer deployment is invalid"
|
|
350
|
+
});
|
|
351
|
+
const serialized = `${JSON.stringify(decoded, null, 2)}\n`;
|
|
352
|
+
assertBoundedSnapshot(serialized);
|
|
353
|
+
const fs = yield* FileSystem.FileSystem;
|
|
354
|
+
yield* fs.makeDirectory(root, { recursive: true, mode: 0o700 });
|
|
355
|
+
yield* fs.chmod(root, 0o700).pipe(Effect.ignore);
|
|
356
|
+
yield* writeFileAtomicEffect(paths.join(root, POINTER_DEPLOYMENT_FILE), serialized, {
|
|
357
|
+
mode: 0o600
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function removeLegacyDeploymentBodies(root) {
|
|
362
|
+
return Effect.gen(function* () {
|
|
363
|
+
const fs = yield* FileSystem.FileSystem;
|
|
364
|
+
const paths = yield* Path.Path;
|
|
365
|
+
for (const file of [DEPLOYMENT_FILE, SNAPSHOT_FILE, PREVIOUS_SNAPSHOT_FILE]) {
|
|
366
|
+
yield* fs.remove(paths.join(root, file), { force: true }).pipe(Effect.ignore);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function readAnyActivation(path, prefix) {
|
|
371
|
+
return Effect.gen(function* () {
|
|
372
|
+
const json = yield* readJsonDocument(path, `${prefix} is corrupt`);
|
|
373
|
+
if (json === undefined)
|
|
374
|
+
return yield* new RouteKitFailure({
|
|
375
|
+
message: `${prefix} does not exist: ${path}`
|
|
376
|
+
});
|
|
377
|
+
const decoded = yield* Schema.decodeUnknownEffect(AnyPublishedRoutingActivation)(json).pipe(Effect.mapError((cause) => new RouteKitFailure({
|
|
378
|
+
message: `${prefix} has the wrong shape: ${String(cause)}`
|
|
379
|
+
})));
|
|
380
|
+
yield* validateAnyActivation(decoded, `${prefix} is invalid`);
|
|
381
|
+
return decoded;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
158
384
|
function writeLegacyCompatibilitySnapshots(root, current, displaced) {
|
|
159
385
|
return Effect.gen(function* () {
|
|
160
386
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -207,6 +433,14 @@ function emptyDeployment(updatedAt) {
|
|
|
207
433
|
updatedAt
|
|
208
434
|
};
|
|
209
435
|
}
|
|
436
|
+
function readResolvedDeployment(root) {
|
|
437
|
+
return Effect.gen(function* () {
|
|
438
|
+
const pointer = yield* readPointerDeployment(root);
|
|
439
|
+
return pointer === undefined
|
|
440
|
+
? yield* readOrMigrateDeployment(root)
|
|
441
|
+
: yield* resolvePointerDeployment(pointer);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
210
444
|
function readOrMigrateDeployment(root) {
|
|
211
445
|
return Effect.gen(function* () {
|
|
212
446
|
const paths = yield* Path.Path;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, mkdirSync, 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";
|
|
@@ -159,17 +159,38 @@ test("legacy V2 files migrate idempotently into cross-version deployment state",
|
|
|
159
159
|
rmSync(root, { recursive: true, force: true });
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
|
+
test("legacy deployments roll back before pointer migration", async () => {
|
|
163
|
+
const root = mkdtempSync(join(tmpdir(), "routekit-routing-legacy-rollback-"));
|
|
164
|
+
try {
|
|
165
|
+
const store = makeRoutingActivationStore(root);
|
|
166
|
+
const first = await runRouteKitEffect(store.publish(publication("legacy-first")));
|
|
167
|
+
const second = await runRouteKitEffect(store.publish(publication("legacy-second")));
|
|
168
|
+
const rolledBack = await runRouteKitEffect(store.rollbackAuthoritative(activationRevisionDigest(second), activationRevisionDigest(first)));
|
|
169
|
+
assert.equal(rolledBack.authoritative?.activationRevisionDigest, activationRevisionDigest(first));
|
|
170
|
+
assert.equal(rolledBack.previousAuthoritative?.activationRevisionDigest, activationRevisionDigest(second));
|
|
171
|
+
assert.equal(statSync(join(root, "routing-deployment.v1.json")).mode & 0o777, 0o600);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
rmSync(root, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
162
177
|
test("V3 authority installation and rollback compare and swap both exact slots", async () => {
|
|
163
178
|
const root = mkdtempSync(join(tmpdir(), "routekit-routing-deployment-rollback-"));
|
|
179
|
+
const repositoryRoot = mkdtempSync(join(tmpdir(), "routekit-routing-repository-"));
|
|
164
180
|
try {
|
|
165
181
|
const store = makeRoutingActivationStore(root);
|
|
166
182
|
const v2 = await runRouteKitEffect(store.publish(publication("v2")));
|
|
167
183
|
const v2Revision = activationRevisionDigest(v2);
|
|
168
184
|
const v3 = JSON.parse(readFileSync(new URL("../../../../test/fixtures/routing-v3/examples/published-routing-activation-v3.example.json", import.meta.url), "utf8"));
|
|
169
|
-
const
|
|
185
|
+
const activationPath = join(repositoryRoot, ".routekit", "routing", "activations", `${v3.activationDigest}.json`);
|
|
186
|
+
mkdirSync(join(repositoryRoot, ".routekit", "routing", "activations"), {
|
|
187
|
+
recursive: true
|
|
188
|
+
});
|
|
189
|
+
writeFileSync(activationPath, `${JSON.stringify(v3)}\n`);
|
|
190
|
+
const installed = await runRouteKitEffect(store.installAuthoritative(v3, activationPath, v2Revision, null));
|
|
170
191
|
assert.equal(installed.authoritative?.activation.version, 3);
|
|
171
192
|
assert.equal(installed.previousAuthoritative?.activationRevisionDigest, v2Revision);
|
|
172
|
-
await assert.rejects(runRouteKitEffect(store.installAuthoritative(v3, v2Revision, null)), (error) => error instanceof RoutingDeploymentConflictError && error.slot === "authoritative");
|
|
193
|
+
await assert.rejects(runRouteKitEffect(store.installAuthoritative(v3, activationPath, v2Revision, null)), (error) => error instanceof RoutingDeploymentConflictError && error.slot === "authoritative");
|
|
173
194
|
const rolledBack = await runRouteKitEffect(store.rollbackAuthoritative(v3.activationDigest, v2Revision));
|
|
174
195
|
assert.equal(rolledBack.authoritative?.activation.version, 2);
|
|
175
196
|
assert.equal(rolledBack.previousAuthoritative?.activation.version, 3);
|
|
@@ -177,5 +198,62 @@ test("V3 authority installation and rollback compare and swap both exact slots",
|
|
|
177
198
|
finally {
|
|
178
199
|
chmodSync(root, 0o700);
|
|
179
200
|
rmSync(root, { recursive: true, force: true });
|
|
201
|
+
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("pointer deployments keep definitions repo-local across repositories", async () => {
|
|
205
|
+
const root = mkdtempSync(join(tmpdir(), "routekit-routing-pointer-"));
|
|
206
|
+
const repositoryA = mkdtempSync(join(tmpdir(), "routekit-routing-repo-a-"));
|
|
207
|
+
const repositoryB = mkdtempSync(join(tmpdir(), "routekit-routing-repo-b-"));
|
|
208
|
+
const activation = (repositoryRoot, evidenceDigest) => {
|
|
209
|
+
const definition = {
|
|
210
|
+
version: 2,
|
|
211
|
+
generatedAt: "2026-08-24T00:00:00.000Z",
|
|
212
|
+
...publication(evidenceDigest)
|
|
213
|
+
};
|
|
214
|
+
const path = join(repositoryRoot, ".routekit", "routing", "activations", `${activationRevisionDigest(definition)}.json`);
|
|
215
|
+
mkdirSync(join(repositoryRoot, ".routekit", "routing", "activations"), {
|
|
216
|
+
recursive: true
|
|
217
|
+
});
|
|
218
|
+
writeFileSync(path, `${JSON.stringify(definition, null, 2)}\n`);
|
|
219
|
+
return { definition, path };
|
|
220
|
+
};
|
|
221
|
+
try {
|
|
222
|
+
const store = makeRoutingActivationStore(root);
|
|
223
|
+
const first = activation(repositoryA, "repository-a");
|
|
224
|
+
const firstRevision = activationRevisionDigest(first.definition);
|
|
225
|
+
await runRouteKitEffect(store.installAuthoritative(first.definition, first.path, null, null));
|
|
226
|
+
const firstDefinitionBefore = readFileSync(first.path, "utf8");
|
|
227
|
+
const second = activation(repositoryB, "repository-b");
|
|
228
|
+
const secondRevision = activationRevisionDigest(second.definition);
|
|
229
|
+
const retainedFirstPath = join(repositoryB, ".routekit", "routing", "activations", `${firstRevision}.json`);
|
|
230
|
+
await runRouteKitEffect(store.installAuthoritative(second.definition, second.path, firstRevision, null));
|
|
231
|
+
assert.equal(readFileSync(first.path, "utf8"), firstDefinitionBefore);
|
|
232
|
+
assert.equal(readFileSync(retainedFirstPath, "utf8"), firstDefinitionBefore);
|
|
233
|
+
assert.equal((await runRouteKitEffect(store.readDeployment())).authoritative
|
|
234
|
+
?.activationRevisionDigest, secondRevision);
|
|
235
|
+
assert.equal((await runRouteKitEffect(store.readDeployment())).previousAuthoritative
|
|
236
|
+
?.activationRevisionDigest, firstRevision);
|
|
237
|
+
const globalPointer = readFileSync(join(root, "routing-deployment.v2.json"), "utf8");
|
|
238
|
+
assert.match(globalPointer, /"activationPath"/u);
|
|
239
|
+
assert.equal(globalPointer.includes('"activation":'), false);
|
|
240
|
+
assert.equal(globalPointer.includes('"dimensions":'), false);
|
|
241
|
+
assert.equal(globalPointer.includes(first.path), false);
|
|
242
|
+
assert.equal(globalPointer.includes(retainedFirstPath), true);
|
|
243
|
+
assert.equal(globalPointer.includes(second.path), true);
|
|
244
|
+
assert.equal(readFileSync(second.path, "utf8").includes('"dimensions":'), true);
|
|
245
|
+
rmSync(repositoryA, { recursive: true, force: true });
|
|
246
|
+
assert.equal((await runRouteKitEffect(store.readDeployment())).authoritative
|
|
247
|
+
?.activationRevisionDigest, secondRevision);
|
|
248
|
+
rmSync(retainedFirstPath, { force: true });
|
|
249
|
+
const third = activation(repositoryB, "repository-c");
|
|
250
|
+
await runRouteKitEffect(store.installAuthoritative(third.definition, third.path, secondRevision, firstRevision));
|
|
251
|
+
assert.equal((await runRouteKitEffect(store.readDeployment())).authoritative
|
|
252
|
+
?.activationRevisionDigest, activationRevisionDigest(third.definition));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
rmSync(root, { recursive: true, force: true });
|
|
256
|
+
rmSync(repositoryA, { recursive: true, force: true });
|
|
257
|
+
rmSync(repositoryB, { recursive: true, force: true });
|
|
180
258
|
}
|
|
181
259
|
});
|
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.
|
|
4
|
+
"version": "1.3.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.
|
|
35
|
-
"@velum-labs/routekit-runtime": "1.
|
|
34
|
+
"@velum-labs/routekit-eval-contracts": "1.3.0",
|
|
35
|
+
"@velum-labs/routekit-runtime": "1.3.0"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"routekit",
|