@camstack/server 1.2.89 → 1.2.91

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.
@@ -0,0 +1,408 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.writePendingFrameworkSwap = writePendingFrameworkSwap;
37
+ exports.applyPendingFrameworkSwap = applyPendingFrameworkSwap;
38
+ exports.rollbackUnconfirmedFrameworkSwap = rollbackUnconfirmedFrameworkSwap;
39
+ exports.confirmFrameworkSwapHealthy = confirmFrameworkSwapHealthy;
40
+ /**
41
+ * Launcher zero-dep framework swap/rollback helpers.
42
+ *
43
+ * These run before @camstack/system is imported — zero @camstack imports.
44
+ * Only node:fs + node:path. All writes are atomic (tmpfile+rename).
45
+ * All reads tolerate missing/corrupt files (no-op result).
46
+ */
47
+ const fs = __importStar(require("node:fs"));
48
+ const path = __importStar(require("node:path"));
49
+ const lifecycle_journal_path_js_1 = require("./lifecycle-journal-path.js");
50
+ // ---------------------------------------------------------------------------
51
+ // Marker file names
52
+ // ---------------------------------------------------------------------------
53
+ const PENDING_SWAP_MARKER = '.pending-framework-swap.json';
54
+ const SWAP_CONFIRM_MARKER = '.framework-swap-confirm.json';
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+ /** Write a JSON file atomically via a tmp sibling + rename. */
59
+ function writeJsonAtomic(filePath, value) {
60
+ const tmp = `${filePath}.tmp`;
61
+ fs.writeFileSync(tmp, JSON.stringify(value), 'utf-8');
62
+ fs.renameSync(tmp, filePath);
63
+ }
64
+ /** Read + parse JSON, returning null on any error (missing / corrupt). */
65
+ function readJsonSafe(filePath) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** Validate that a parsed value matches the PendingSwapMarker shape. */
74
+ function isPendingSwapMarker(value) {
75
+ if (typeof value !== 'object' || value === null)
76
+ return false;
77
+ const v = value;
78
+ if (typeof v['jobId'] !== 'string')
79
+ return false;
80
+ if (typeof v['taskId'] !== 'string')
81
+ return false;
82
+ if (!Array.isArray(v['packages']))
83
+ return false;
84
+ for (const pkg of v['packages']) {
85
+ if (typeof pkg !== 'object' || pkg === null)
86
+ return false;
87
+ const p = pkg;
88
+ if (typeof p['name'] !== 'string')
89
+ return false;
90
+ if (typeof p['stagedPath'] !== 'string')
91
+ return false;
92
+ if (typeof p['backupPath'] !== 'string')
93
+ return false;
94
+ }
95
+ if (typeof v['requestedAtMs'] !== 'number')
96
+ return false;
97
+ if (v['schemaVersion'] !== 1)
98
+ return false;
99
+ return true;
100
+ }
101
+ /** Validate that a parsed value matches the SwapConfirmMarker shape. */
102
+ function isSwapConfirmMarker(value) {
103
+ if (typeof value !== 'object' || value === null)
104
+ return false;
105
+ const v = value;
106
+ if (typeof v['jobId'] !== 'string')
107
+ return false;
108
+ if (typeof v['taskId'] !== 'string')
109
+ return false;
110
+ if (!Array.isArray(v['backups']))
111
+ return false;
112
+ for (const b of v['backups']) {
113
+ if (typeof b !== 'object' || b === null)
114
+ return false;
115
+ const entry = b;
116
+ if (typeof entry['name'] !== 'string')
117
+ return false;
118
+ if (typeof entry['backupPath'] !== 'string')
119
+ return false;
120
+ if (typeof entry['livePath'] !== 'string')
121
+ return false;
122
+ }
123
+ if (typeof v['appliedAtMs'] !== 'number')
124
+ return false;
125
+ if (typeof v['bootAttempts'] !== 'number')
126
+ return false;
127
+ if (v['schemaVersion'] !== 1)
128
+ return false;
129
+ return true;
130
+ }
131
+ /**
132
+ * Patch a specific task inside a journal job file atomically.
133
+ * Reads `<dataDir>/lifecycle/jobs/<jobId>.json`, updates tasks[taskId],
134
+ * and writes back atomically. Silently no-ops on any read/write error.
135
+ */
136
+ function patchJournalTask(dataDir, jobId, taskId, patch) {
137
+ const jobFile = path.join((0, lifecycle_journal_path_js_1.lifecycleJobsDir)(dataDir), `${jobId}.json`);
138
+ const raw = readJsonSafe(jobFile);
139
+ if (typeof raw !== 'object' || raw === null)
140
+ return;
141
+ const job = raw;
142
+ const tasks = job['tasks'];
143
+ if (!Array.isArray(tasks))
144
+ return;
145
+ const updatedTasks = tasks.map((task) => {
146
+ if (typeof task !== 'object' || task === null)
147
+ return task;
148
+ const t = task;
149
+ if (t['taskId'] !== taskId)
150
+ return task;
151
+ const updated = { ...t, phase: patch.phase };
152
+ if (patch.error !== undefined)
153
+ updated['error'] = patch.error;
154
+ if (patch.finishedAtMs !== undefined)
155
+ updated['finishedAtMs'] = patch.finishedAtMs;
156
+ return updated;
157
+ });
158
+ try {
159
+ writeJsonAtomic(jobFile, { ...job, tasks: updatedTasks });
160
+ }
161
+ catch {
162
+ // journal patch is best-effort — never crash the launcher
163
+ }
164
+ }
165
+ /** Copy a directory tree cross-device (fallback for EXDEV rename errors). */
166
+ function moveDirCrossDevice(src, dest) {
167
+ fs.cpSync(src, dest, { recursive: true });
168
+ fs.rmSync(src, { recursive: true, force: true });
169
+ }
170
+ /**
171
+ * Write the `.pending-framework-swap.json` marker atomically.
172
+ * Co-located with the reader (`applyPendingFrameworkSwap`) for symmetry.
173
+ * Zero @camstack deps — this file runs before @camstack/system is imported.
174
+ */
175
+ function writePendingFrameworkSwap(dataDir, marker) {
176
+ const markerPath = path.join(dataDir, PENDING_SWAP_MARKER);
177
+ writeJsonAtomic(markerPath, marker);
178
+ }
179
+ /**
180
+ * Apply a pending framework swap if a `.pending-framework-swap.json` marker
181
+ * exists in `dataDir`. Swaps each listed package atomically:
182
+ * 1. Backup: rename live → backupPath
183
+ * 2. Swap: rename staged → live (EXDEV fallback: cpSync + rmSync)
184
+ *
185
+ * On any mid-loop failure: restores already-swapped packages, deletes the
186
+ * marker, returns `{ applied: false }`.
187
+ * On success: writes `.framework-swap-confirm.json`, patches the journal
188
+ * task to `applied`, deletes the swap marker, returns `{ applied: true, jobId }`.
189
+ */
190
+ function applyPendingFrameworkSwap(dataDir, frameworkDir) {
191
+ if (!frameworkDir)
192
+ return { applied: false };
193
+ const markerPath = path.join(dataDir, PENDING_SWAP_MARKER);
194
+ if (!fs.existsSync(markerPath))
195
+ return { applied: false };
196
+ const raw = readJsonSafe(markerPath);
197
+ if (!isPendingSwapMarker(raw)) {
198
+ // malformed — delete to prevent blocking future updates
199
+ try {
200
+ fs.rmSync(markerPath, { force: true });
201
+ }
202
+ catch {
203
+ // ignore deletion error
204
+ }
205
+ return { applied: false };
206
+ }
207
+ const marker = raw;
208
+ const swapped = [];
209
+ for (const pkg of marker.packages) {
210
+ const livePath = path.join(frameworkDir, 'node_modules', pkg.name);
211
+ try {
212
+ // Step 1: backup live dir if it exists
213
+ if (fs.existsSync(livePath)) {
214
+ fs.mkdirSync(path.dirname(pkg.backupPath), { recursive: true });
215
+ fs.renameSync(livePath, pkg.backupPath);
216
+ }
217
+ // Step 2: move staged dir to live
218
+ try {
219
+ fs.mkdirSync(path.dirname(livePath), { recursive: true });
220
+ fs.renameSync(pkg.stagedPath, livePath);
221
+ }
222
+ catch (err) {
223
+ if (err.code === 'EXDEV') {
224
+ fs.mkdirSync(path.dirname(livePath), { recursive: true });
225
+ moveDirCrossDevice(pkg.stagedPath, livePath);
226
+ }
227
+ else {
228
+ throw err;
229
+ }
230
+ }
231
+ swapped.push({ livePath, backupPath: pkg.backupPath });
232
+ }
233
+ catch {
234
+ // Restore all already-swapped packages
235
+ for (const done of swapped) {
236
+ try {
237
+ if (fs.existsSync(done.livePath)) {
238
+ fs.rmSync(done.livePath, { recursive: true, force: true });
239
+ }
240
+ if (fs.existsSync(done.backupPath)) {
241
+ fs.mkdirSync(path.dirname(done.livePath), { recursive: true });
242
+ fs.renameSync(done.backupPath, done.livePath);
243
+ }
244
+ }
245
+ catch {
246
+ // best-effort restore
247
+ }
248
+ }
249
+ // Delete the pending marker so the next boot doesn't retry a broken swap
250
+ try {
251
+ fs.rmSync(markerPath, { force: true });
252
+ }
253
+ catch {
254
+ // ignore
255
+ }
256
+ return { applied: false };
257
+ }
258
+ }
259
+ // All packages swapped successfully — write confirm marker
260
+ const backups = marker.packages.map((pkg) => ({
261
+ name: pkg.name,
262
+ backupPath: pkg.backupPath,
263
+ livePath: path.join(frameworkDir, 'node_modules', pkg.name),
264
+ }));
265
+ const confirmMarker = {
266
+ jobId: marker.jobId,
267
+ taskId: marker.taskId,
268
+ backups,
269
+ appliedAtMs: Date.now(),
270
+ bootAttempts: 0,
271
+ schemaVersion: 1,
272
+ };
273
+ // Write confirm marker — if this fails, restore everything and fail the apply
274
+ try {
275
+ writeJsonAtomic(path.join(dataDir, SWAP_CONFIRM_MARKER), confirmMarker);
276
+ }
277
+ catch {
278
+ // Confirm marker write failed — restore all swapped packages and fail the apply
279
+ for (const done of swapped) {
280
+ try {
281
+ if (fs.existsSync(done.livePath)) {
282
+ fs.rmSync(done.livePath, { recursive: true, force: true });
283
+ }
284
+ if (fs.existsSync(done.backupPath)) {
285
+ fs.mkdirSync(path.dirname(done.livePath), { recursive: true });
286
+ fs.renameSync(done.backupPath, done.livePath);
287
+ }
288
+ }
289
+ catch {
290
+ // best-effort restore
291
+ }
292
+ }
293
+ // Delete the pending marker so the next boot doesn't retry
294
+ try {
295
+ fs.rmSync(markerPath, { force: true });
296
+ }
297
+ catch {
298
+ // ignore
299
+ }
300
+ return { applied: false };
301
+ }
302
+ // Patch journal task → applied (best-effort, doesn't fail the apply)
303
+ patchJournalTask(dataDir, marker.jobId, marker.taskId, { phase: 'applied' });
304
+ // Delete the swap marker
305
+ try {
306
+ fs.rmSync(markerPath, { force: true });
307
+ }
308
+ catch {
309
+ // ignore
310
+ }
311
+ return { applied: true, jobId: marker.jobId };
312
+ }
313
+ /**
314
+ * Check for an unconfirmed framework swap on boot.
315
+ *
316
+ * - `bootAttempts === 0`: this is the probation boot. Increment to 1,
317
+ * rewrite the marker, return `{ rolledBack: false }`.
318
+ * - `bootAttempts >= 1`: the previous probation boot crashed / didn't call
319
+ * `confirmFrameworkSwapHealthy`. Restore all backups, patch the journal
320
+ * task to `failed`, delete the confirm marker, return `{ rolledBack: true }`.
321
+ */
322
+ function rollbackUnconfirmedFrameworkSwap(dataDir, frameworkDir) {
323
+ if (!frameworkDir)
324
+ return { rolledBack: false };
325
+ const confirmPath = path.join(dataDir, SWAP_CONFIRM_MARKER);
326
+ if (!fs.existsSync(confirmPath))
327
+ return { rolledBack: false };
328
+ const raw = readJsonSafe(confirmPath);
329
+ if (!isSwapConfirmMarker(raw))
330
+ return { rolledBack: false };
331
+ const confirm = raw;
332
+ if (confirm.bootAttempts === 0) {
333
+ // Probation boot — increment attempts and let this boot proceed
334
+ const updated = { ...confirm, bootAttempts: 1 };
335
+ try {
336
+ writeJsonAtomic(confirmPath, updated);
337
+ }
338
+ catch {
339
+ // ignore write failure
340
+ }
341
+ return { rolledBack: false };
342
+ }
343
+ // bootAttempts >= 1: probation boot didn't become healthy — rollback
344
+ for (const backup of confirm.backups) {
345
+ try {
346
+ if (fs.existsSync(backup.livePath)) {
347
+ fs.rmSync(backup.livePath, { recursive: true, force: true });
348
+ }
349
+ if (fs.existsSync(backup.backupPath)) {
350
+ fs.mkdirSync(path.dirname(backup.livePath), { recursive: true });
351
+ fs.renameSync(backup.backupPath, backup.livePath);
352
+ }
353
+ }
354
+ catch {
355
+ // best-effort restore
356
+ }
357
+ }
358
+ patchJournalTask(dataDir, confirm.jobId, confirm.taskId, {
359
+ phase: 'failed',
360
+ error: 'framework update rolled back — boot did not become healthy',
361
+ finishedAtMs: Date.now(),
362
+ });
363
+ try {
364
+ fs.rmSync(confirmPath, { force: true });
365
+ }
366
+ catch {
367
+ // ignore
368
+ }
369
+ return { rolledBack: true };
370
+ }
371
+ /**
372
+ * Called by post-boot health check once the hub is confirmed healthy after
373
+ * a framework swap. Deletes all backups and the confirm marker so the old
374
+ * framework packages are cleaned up.
375
+ */
376
+ function confirmFrameworkSwapHealthy(dataDir) {
377
+ const confirmPath = path.join(dataDir, SWAP_CONFIRM_MARKER);
378
+ if (!fs.existsSync(confirmPath))
379
+ return;
380
+ const raw = readJsonSafe(confirmPath);
381
+ if (!isSwapConfirmMarker(raw)) {
382
+ // corrupt marker — still delete it
383
+ try {
384
+ fs.rmSync(confirmPath, { force: true });
385
+ }
386
+ catch {
387
+ // ignore
388
+ }
389
+ return;
390
+ }
391
+ const confirm = raw;
392
+ // Delete all backup dirs
393
+ for (const backup of confirm.backups) {
394
+ try {
395
+ fs.rmSync(backup.backupPath, { recursive: true, force: true });
396
+ }
397
+ catch {
398
+ // best-effort cleanup
399
+ }
400
+ }
401
+ // Delete the confirm marker
402
+ try {
403
+ fs.rmSync(confirmPath, { force: true });
404
+ }
405
+ catch {
406
+ // ignore
407
+ }
408
+ }
package/dist/main.js CHANGED
@@ -867,17 +867,26 @@ async function bootstrap() {
867
867
  const upstreamPath = `/${subPath}${queryString}`;
868
868
  // Take over the socket — `proxyToUpstream` drives the raw response.
869
869
  reply.hijack();
870
+ const replayBody = request.body === undefined
871
+ ? undefined
872
+ : Buffer.isBuffer(request.body)
873
+ ? request.body
874
+ : Buffer.from(typeof request.body === 'string' ? request.body : JSON.stringify(request.body));
870
875
  (0, system_3.proxyToUpstream)({
871
876
  baseUrl: dpMatch.endpoint.baseUrl,
872
877
  secret: dpMatch.endpoint.secret,
873
878
  upstreamPath,
874
879
  clientReq: request.raw,
875
880
  clientRes: reply.raw,
881
+ ...(replayBody !== undefined ? { replayBody } : {}),
876
882
  });
877
883
  return;
878
884
  }
879
885
  const match = addonRouteRegistry.matchRoute(method, fullPath);
880
886
  if (!match) {
887
+ if ((0, spa_static_1.isRetiredPublicPath)(fullPath)) {
888
+ return reply.status(404).send({ error: 'Not found' });
889
+ }
881
890
  if (method === 'GET' && spaIndexHtml) {
882
891
  return reply.type('text/html').send(fs.createReadStream(spaIndexHtml));
883
892
  }
@@ -1111,6 +1120,8 @@ async function bootstrap() {
1111
1120
  });
1112
1121
  fastify.get('/*', async (request, reply) => {
1113
1122
  const url = request.url;
1123
+ if ((0, spa_static_1.isRetiredPublicPath)(url))
1124
+ return reply.callNotFound();
1114
1125
  if (url.startsWith('/trpc') ||
1115
1126
  url.startsWith('/api/') ||
1116
1127
  url.startsWith('/agent') ||
@@ -220,6 +220,7 @@ async function bootManual(opts) {
220
220
  const serverUpdateService = new server_update_service_1.ServerUpdateService({
221
221
  logger: loggingService.createLogger('ServerUpdate'),
222
222
  restartServer: (requestedBy) => addonPackageService.restartServer(requestedBy),
223
+ eventBus: eventBusService,
223
224
  });
224
225
  const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
225
226
  const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requestFrameworkSwapAndRestart = requestFrameworkSwapAndRestart;
4
+ /**
5
+ * The single seam that joins the two halves of a framework-swap request:
6
+ * 1. write the durable `.pending-framework-swap.json` marker (consumed by the
7
+ * zero-dep launcher on the NEXT boot), and
8
+ * 2. schedule the self-restart that makes the launcher actually run.
9
+ *
10
+ * The launcher (`launcher-framework-swap.ts`) runs before `@camstack/system`
11
+ * is imported and is deliberately zero-dep, so it cannot own the restart — that
12
+ * lives in `@camstack/system`. This module is the ONLY owner of "stage a
13
+ * framework swap → reboot": every engine path that stages the framework (single
14
+ * `updateFrameworkPackage`, the "Update all" bulk job, and the auto-update
15
+ * scheduler) wires its `requestFrameworkSwap` dep to this helper and gets the
16
+ * reboot for free. The lifecycle engine assumes the process exits after
17
+ * `requestFrameworkSwap` returns; without the restart here the marker is written
18
+ * but never applied, silently stranding the update on disk.
19
+ */
20
+ const system_1 = require("@camstack/system");
21
+ const launcher_framework_swap_js_1 = require("./launcher-framework-swap.js");
22
+ /** Delay before the hub exits — lets the in-flight startJob reply drain first. */
23
+ const FRAMEWORK_SWAP_RESTART_DELAY_MS = 500;
24
+ /**
25
+ * Write the pending-swap marker, then schedule the self-restart. The restart is
26
+ * UNCONDITIONAL: a staged framework swap is inert until the launcher applies it
27
+ * on reboot, so staging without restarting would silently lose the update.
28
+ */
29
+ function requestFrameworkSwapAndRestart(dataDir, input, deps = {}) {
30
+ const writeMarker = deps.writeMarker ?? launcher_framework_swap_js_1.writePendingFrameworkSwap;
31
+ const scheduleRestart = deps.scheduleRestart ?? system_1.scheduleSelfRestart;
32
+ const now = deps.now ?? Date.now;
33
+ writeMarker(dataDir, {
34
+ jobId: input.jobId,
35
+ taskId: input.taskId,
36
+ packages: input.packages,
37
+ requestedAtMs: now(),
38
+ schemaVersion: 1,
39
+ });
40
+ scheduleRestart({ delayMs: FRAMEWORK_SWAP_RESTART_DELAY_MS });
41
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planBoot = planBoot;
4
+ /**
5
+ * Decide the boot target. Never throws; never mutates `state`.
6
+ * `stateToWrite` is non-null whenever the durable state must change
7
+ * (probation counter bump, rollback, invalid-version eviction).
8
+ */
9
+ function planBoot(state, isValidVersion, now) {
10
+ if (state === null) {
11
+ return { kind: 'baked', reason: 'no server-root state', stateToWrite: null };
12
+ }
13
+ let next = state;
14
+ let changed = false;
15
+ // ── Pending (freshly-staged) version ──────────────────────────────────
16
+ if (next.pendingBoot !== null) {
17
+ const pending = next.pendingBoot;
18
+ if (pending.bootAttempts >= 1) {
19
+ // Previous probation boot never confirmed healthy — roll back.
20
+ next = {
21
+ ...next,
22
+ pendingBoot: null,
23
+ rolledBack: {
24
+ fromVersion: pending.version,
25
+ toVersion: next.currentVersion,
26
+ atMs: now(),
27
+ reason: 'probation boot did not reach ready — rolled back',
28
+ },
29
+ };
30
+ changed = true;
31
+ }
32
+ else if (!isValidVersion(pending.version)) {
33
+ next = {
34
+ ...next,
35
+ pendingBoot: null,
36
+ rolledBack: {
37
+ fromVersion: pending.version,
38
+ toVersion: next.currentVersion,
39
+ atMs: now(),
40
+ reason: 'staged version failed validation',
41
+ },
42
+ };
43
+ changed = true;
44
+ }
45
+ else {
46
+ // Probation boot: bump the attempt counter durably, keep currentVersion
47
+ // untouched until the hub confirms the boot healthy.
48
+ return {
49
+ kind: 'data-root',
50
+ version: pending.version,
51
+ probation: true,
52
+ stateToWrite: {
53
+ ...next,
54
+ pendingBoot: { ...pending, bootAttempts: pending.bootAttempts + 1 },
55
+ },
56
+ };
57
+ }
58
+ }
59
+ // ── Confirmed current version ──────────────────────────────────────────
60
+ if (next.currentVersion !== null) {
61
+ if (isValidVersion(next.currentVersion)) {
62
+ return {
63
+ kind: 'data-root',
64
+ version: next.currentVersion,
65
+ probation: false,
66
+ stateToWrite: changed ? next : null,
67
+ };
68
+ }
69
+ // Current version dir is broken — try N-1, else baked.
70
+ const broken = next.currentVersion;
71
+ if (next.previousVersion !== null && isValidVersion(next.previousVersion)) {
72
+ const fallback = next.previousVersion;
73
+ return {
74
+ kind: 'data-root',
75
+ version: fallback,
76
+ probation: false,
77
+ stateToWrite: {
78
+ ...next,
79
+ currentVersion: fallback,
80
+ previousVersion: null,
81
+ rolledBack: {
82
+ fromVersion: broken,
83
+ toVersion: fallback,
84
+ atMs: now(),
85
+ reason: 'active version failed validation',
86
+ },
87
+ },
88
+ };
89
+ }
90
+ return {
91
+ kind: 'baked',
92
+ reason: `active version ${broken} failed validation and no valid previous version exists`,
93
+ stateToWrite: {
94
+ ...next,
95
+ currentVersion: null,
96
+ rolledBack: {
97
+ fromVersion: broken,
98
+ toVersion: null,
99
+ atMs: now(),
100
+ reason: 'active version failed validation',
101
+ },
102
+ },
103
+ };
104
+ }
105
+ return {
106
+ kind: 'baked',
107
+ reason: 'no active data-root version',
108
+ stateToWrite: changed ? next : null,
109
+ };
110
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ /**
3
+ * Minimal semver comparison — zero-dep (used by the starter, which must never
4
+ * import from node_modules). Handles `MAJOR.MINOR.PATCH[-prerelease]`:
5
+ * numeric segment compare, missing segments = 0, prerelease < its release,
6
+ * prereleases of the same base compare lexically. Malformed segments compare
7
+ * as 0 (best effort — never throws).
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.compareSemver = compareSemver;
11
+ function parse(version) {
12
+ const dashIdx = version.indexOf('-');
13
+ const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
14
+ const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
15
+ const nums = base.split('.').map((seg) => {
16
+ const n = Number.parseInt(seg, 10);
17
+ return Number.isNaN(n) ? 0 : n;
18
+ });
19
+ return { nums, prerelease };
20
+ }
21
+ /** Returns -1 when a < b, 0 when equal, 1 when a > b. */
22
+ function compareSemver(a, b) {
23
+ const pa = parse(a);
24
+ const pb = parse(b);
25
+ const len = Math.max(pa.nums.length, pb.nums.length);
26
+ for (let i = 0; i < len; i++) {
27
+ const na = pa.nums[i] ?? 0;
28
+ const nb = pb.nums[i] ?? 0;
29
+ if (na < nb)
30
+ return -1;
31
+ if (na > nb)
32
+ return 1;
33
+ }
34
+ if (pa.prerelease === null && pb.prerelease === null)
35
+ return 0;
36
+ if (pa.prerelease === null)
37
+ return 1;
38
+ if (pb.prerelease === null)
39
+ return -1;
40
+ if (pa.prerelease < pb.prerelease)
41
+ return -1;
42
+ if (pa.prerelease > pb.prerelease)
43
+ return 1;
44
+ return 0;
45
+ }