@valbuild/server 0.103.0 → 0.103.1

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.
@@ -8,12 +8,13 @@ import path__default from 'path';
8
8
  import fs, { promises } from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module, createRequire } from 'node:module';
11
- import { resolveSchemaSourceFixForError, getErrorMessageFromUnknownJson, Patch, ParentRef, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
11
+ import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
15
  import sizeOf from 'image-size';
16
16
  import { fromError } from 'zod-validation-error';
17
+ import os from 'os';
17
18
  import { transform } from 'sucrase';
18
19
  import http from 'http';
19
20
  import https from 'https';
@@ -3268,49 +3269,1030 @@ class JsonEntryFilesFingerprint {
3268
3269
  }
3269
3270
 
3270
3271
  /**
3271
- * Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
3272
+ * The order of the pending patches, held in one flat, append-only text file.
3272
3273
  *
3273
- * NOTE: patches that will be deleted are not included in the changed patches, since they will be deleted any how.
3274
+ * ## Why a log and not links between the patches
3274
3275
  *
3275
- * @param currentPatches - The array of current patches.
3276
- * @param deletePatchIds - The array of patch IDs to be deleted.
3277
- * @returns An object containing the changed patches with their corresponding parent references.
3276
+ * The store this replaces kept the order in the patches themselves: every record
3277
+ * carried a `parentRef`, and the directory a record lived in was named after that
3278
+ * parent. Reading the chain meant walking those links from `head`, and the walk
3279
+ * stopped dead — silently — at the first id nothing on disk answered for.
3280
+ *
3281
+ * That is not a hypothetical. A store with 410 patches lost exactly one record,
3282
+ * and the 51 patches written after it became unreachable: `/stat` counted the
3283
+ * directories and announced 410, `GET /patches` walked the links and delivered
3284
+ * 359, and the studio waited forever for 51 ids that no longer had a path back to
3285
+ * `head`. One missing file cost every edit made after it.
3286
+ *
3287
+ * So the order lives in one place and the patches reference nothing. A patch
3288
+ * directory is self-contained data; this file says what order the directories go
3289
+ * in. Removing a line cannot orphan the lines below it, because there is nothing
3290
+ * below it to orphan — the entry after a dropped one is simply next.
3291
+ *
3292
+ * ## Why text
3293
+ *
3294
+ * It is read by people during exactly the incidents that make it interesting, and
3295
+ * `cat` should be enough. One entry per line, whitespace-separated, id first:
3296
+ *
3297
+ * ```
3298
+ * val-patch-log v1
3299
+ * 659b8cfa-065c-47d1-8d82-fc69a2ac72a9 2026-08-27T11:46:13.730Z /content/authors.val.ts
3300
+ * ```
3301
+ *
3302
+ * Position in the file IS the order. There is deliberately no sequence number:
3303
+ * a number stored beside the thing it describes is a number that can disagree
3304
+ * with it, and then something has to decide which one lies.
3305
+ *
3306
+ * The timestamp and path are there to make the file readable, not to be believed
3307
+ * — `patch.json` owns those fields. Nothing here is a second copy of state that
3308
+ * anything reads back.
3278
3309
  */
3279
- function computeChangedPatchParentRefs(currentPatches, deletePatchIds) {
3280
- let lastNonDeletedPatchIndex = -1;
3281
- const changedPatches = {};
3282
- for (let i = 0; i < currentPatches.length; i++) {
3283
- const current = currentPatches[i];
3284
- if (
3285
- // skip all patches that will be deleted:
3286
- deletePatchIds.includes(current.patchId)) {
3287
- var _currentPatches;
3288
- if (
3289
- // skip change if the patch after is deleted anyway:
3290
- !deletePatchIds.includes((_currentPatches = currentPatches[i + 1]) === null || _currentPatches === void 0 ? void 0 : _currentPatches.patchId)) {
3291
- if (
3292
- // set next patch to point to head if it exists:
3293
- lastNonDeletedPatchIndex === -1 && currentPatches[i + 1]) {
3294
- changedPatches[currentPatches[i + 1].patchId] = {
3295
- type: "head",
3296
- headBaseSha: current.baseSha
3297
- };
3298
- } else if (
3299
- // set next patch to point to the last non-deleted patch:
3300
- currentPatches[lastNonDeletedPatchIndex] && currentPatches[i + 1]) {
3301
- changedPatches[currentPatches[i + 1].patchId] = {
3302
- type: "patch",
3303
- patchId: currentPatches[lastNonDeletedPatchIndex].patchId
3304
- };
3310
+
3311
+ const PATCH_LOG_FILE_NAME = "patches.log";
3312
+ const HEADER = "val-patch-log v1";
3313
+
3314
+ /**
3315
+ * Something that is wrong with the file but does not stop it being read.
3316
+ *
3317
+ * Reported rather than thrown, because a log that is 99% intact is worth reading
3318
+ * and then repairing. Only a file that cannot be understood at all is fatal, and
3319
+ * that is a `status` on the read result, not a problem in this list.
3320
+ */
3321
+
3322
+ function isModuleFilePath(path) {
3323
+ return path.startsWith("/") && path.includes(".val.");
3324
+ }
3325
+ function isPatchId(patchId) {
3326
+ return patchId.length > 0 && !/\s/.test(patchId);
3327
+ }
3328
+ function formatPatchLogLine(entry) {
3329
+ return `${entry.patchId} ${entry.createdAt} ${entry.path}`;
3330
+ }
3331
+
3332
+ /**
3333
+ * Split on the first two runs of whitespace only: a module file path may contain
3334
+ * spaces, and it is last precisely so that it can.
3335
+ */
3336
+ function parsePatchLogLine(line) {
3337
+ const trimmed = line.trim();
3338
+ if (trimmed === "") {
3339
+ return null;
3340
+ }
3341
+ const match = /^(\S+)\s+(\S+)\s+(.+)$/.exec(trimmed);
3342
+ if (!match) {
3343
+ return null;
3344
+ }
3345
+ const [, patchId, createdAt, path] = match;
3346
+ if (!isPatchId(patchId) || !isModuleFilePath(path)) {
3347
+ return null;
3348
+ }
3349
+ return {
3350
+ patchId,
3351
+ createdAt,
3352
+ path
3353
+ };
3354
+ }
3355
+ function serializePatchLog(entries) {
3356
+ return [HEADER, ...entries.map(formatPatchLogLine), ""].join("\n");
3357
+ }
3358
+ function readPatchLog(logFilePath) {
3359
+ let raw;
3360
+ try {
3361
+ raw = fs.readFileSync(logFilePath, "utf-8");
3362
+ } catch (err) {
3363
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
3364
+ return {
3365
+ status: "absent"
3366
+ };
3367
+ }
3368
+ return {
3369
+ status: "unreadable",
3370
+ message: `Could not read ${logFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
3371
+ };
3372
+ }
3373
+ return parsePatchLog(raw);
3374
+ }
3375
+ function parsePatchLog(raw) {
3376
+ const problems = [];
3377
+ // A zero-byte file is a store that was created and then interrupted before the
3378
+ // header landed. Empty, not broken: there are no patches either way.
3379
+ if (raw === "") {
3380
+ return {
3381
+ status: "ok",
3382
+ entries: [],
3383
+ problems
3384
+ };
3385
+ }
3386
+ const endsWithNewline = raw.endsWith("\n");
3387
+ const lines = raw.split("\n");
3388
+ // `split` always leaves a trailing "" when the text ends in a newline; when it
3389
+ // does not, the last element is a line whose write never finished.
3390
+ const tornFinalLine = endsWithNewline ? null : lines[lines.length - 1];
3391
+ lines.pop();
3392
+ if (tornFinalLine !== null && tornFinalLine.trim() !== "") {
3393
+ problems.push({
3394
+ type: "torn-final-line",
3395
+ line: tornFinalLine
3396
+ });
3397
+ }
3398
+
3399
+ // Only consume the first line when it really is the header. A headerless file
3400
+ // is reported, but its first line is still offered to the entry parser rather
3401
+ // than eaten: dropping a real patch to pay for a missing header would be the
3402
+ // same silent data loss this store exists to stop.
3403
+ const firstLine = (lines[0] ?? "").replace(/\r$/, "");
3404
+ let firstEntryIndex = 0;
3405
+ if (firstLine === HEADER) {
3406
+ firstEntryIndex = 1;
3407
+ } else if (firstLine.startsWith("val-patch-log ")) {
3408
+ problems.push({
3409
+ type: "unsupported-version",
3410
+ header: firstLine
3411
+ });
3412
+ firstEntryIndex = 1;
3413
+ } else {
3414
+ problems.push({
3415
+ type: "missing-header",
3416
+ firstLine
3417
+ });
3418
+ }
3419
+ const entries = [];
3420
+ const seen = new Set();
3421
+ lines.slice(firstEntryIndex).forEach((rawLine, index) => {
3422
+ const line = rawLine.replace(/\r$/, "");
3423
+ // 1-based and counted from the top of the file, so they match what an editor
3424
+ // shows the person reading this report.
3425
+ const lineNumber = firstEntryIndex + index + 1;
3426
+ if (line.trim() === "") {
3427
+ return;
3428
+ }
3429
+ const entry = parsePatchLogLine(line);
3430
+ if (entry === null) {
3431
+ problems.push({
3432
+ type: "unparseable-line",
3433
+ lineNumber,
3434
+ line
3435
+ });
3436
+ return;
3437
+ }
3438
+ if (seen.has(entry.patchId)) {
3439
+ problems.push({
3440
+ type: "duplicate-entry",
3441
+ patchId: entry.patchId,
3442
+ lineNumber
3443
+ });
3444
+ return;
3445
+ }
3446
+ seen.add(entry.patchId);
3447
+ entries.push(entry);
3448
+ });
3449
+ return {
3450
+ status: "ok",
3451
+ entries,
3452
+ problems
3453
+ };
3454
+ }
3455
+
3456
+ /**
3457
+ * fsync the directory holding a file, so a rename or create survives a power cut.
3458
+ *
3459
+ * Best-effort: opening a directory is not permitted on Windows, and a store that
3460
+ * refuses to work there to buy durability nobody asked for is the worse trade.
3461
+ */
3462
+ function fsyncDir(dirPath) {
3463
+ let fd;
3464
+ try {
3465
+ fd = fs.openSync(dirPath, "r");
3466
+ fs.fsyncSync(fd);
3467
+ } catch {
3468
+ // ignore: see above
3469
+ } finally {
3470
+ if (fd !== undefined) {
3471
+ try {
3472
+ fs.closeSync(fd);
3473
+ } catch {
3474
+ // ignore
3475
+ }
3476
+ }
3477
+ }
3478
+ }
3479
+
3480
+ /**
3481
+ * Add one entry to the end of the log.
3482
+ *
3483
+ * A single `writeSync` on an append-only descriptor, then fsync: the whole line
3484
+ * reaches the file or none of it does, and a reader that catches it mid-flight
3485
+ * discards the partial last line rather than misreading it.
3486
+ *
3487
+ * Callers must hold the patch lock. That is what makes "the torn line can only be
3488
+ * the last one" true, and it is why this does not try to be safe against
3489
+ * concurrent appends on its own.
3490
+ */
3491
+ function appendPatchLogEntry(logFilePath, entry) {
3492
+ const dirPath = path__default.dirname(logFilePath);
3493
+ fs.mkdirSync(dirPath, {
3494
+ recursive: true
3495
+ });
3496
+ if (!fs.existsSync(logFilePath)) {
3497
+ writePatchLogFile(logFilePath, []);
3498
+ }
3499
+ const fd = fs.openSync(logFilePath, "a");
3500
+ try {
3501
+ fs.writeSync(fd, `${formatPatchLogLine(entry)}\n`);
3502
+ fs.fsyncSync(fd);
3503
+ } finally {
3504
+ fs.closeSync(fd);
3505
+ }
3506
+ }
3507
+
3508
+ /**
3509
+ * Replace the log wholesale — used by delete and by repair.
3510
+ *
3511
+ * Write a sibling temp file, fsync it, then rename over the original: a rename is
3512
+ * atomic, so a reader either sees the old log or the new one and never a
3513
+ * half-rewritten file. In-place truncation would have a window where the log is
3514
+ * short and the store looks like it lost patches.
3515
+ *
3516
+ * Callers must hold the patch lock.
3517
+ */
3518
+ function writePatchLogFile(logFilePath, entries) {
3519
+ const dirPath = path__default.dirname(logFilePath);
3520
+ fs.mkdirSync(dirPath, {
3521
+ recursive: true
3522
+ });
3523
+ const tmpPath = `${logFilePath}.tmp-${process.pid}`;
3524
+ const fd = fs.openSync(tmpPath, "w");
3525
+ try {
3526
+ fs.writeSync(fd, serializePatchLog(entries));
3527
+ fs.fsyncSync(fd);
3528
+ } finally {
3529
+ fs.closeSync(fd);
3530
+ }
3531
+ fs.renameSync(tmpPath, logFilePath);
3532
+ fsyncDir(dirPath);
3533
+ }
3534
+
3535
+ /**
3536
+ * The on-disk shape of the local-dev patch store.
3537
+ *
3538
+ * ```
3539
+ * .val/patches/
3540
+ * patches.log the order, and the only place it lives
3541
+ * patches.repair.log what repair has done, for the person who has to know
3542
+ * <patchId>/patch.json one plain, self-contained directory per patch
3543
+ * <patchId>/base.json written when the patch is published
3544
+ * <patchId>/files/… binary payloads for this patch's file ops
3545
+ * ```
3546
+ *
3547
+ * A directory is named after the patch it holds, and a record references nothing
3548
+ * outside itself. That is the whole design, and it is a direct answer to how the
3549
+ * old layout failed: there, a directory was named after a record's PARENT, so
3550
+ * reading the store meant following links, and one absent record silently cut off
3551
+ * every patch written after it.
3552
+ *
3553
+ * The invariant this module exists to hold up is narrow and worth stating: **the
3554
+ * announced set and the delivered set are the same array.** `getStat` and
3555
+ * `fetchPatches` both come out of one `readPatchStore` call, so they cannot report
3556
+ * different numbers - which is exactly what they did when this broke, announcing
3557
+ * 410 patches and delivering 359 with no error in between.
3558
+ */
3559
+
3560
+ const PATCH_REPAIR_LOG_FILE_NAME = "patches.repair.log";
3561
+ const FSPatch = z.object({
3562
+ path: z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
3563
+ patch: Patch,
3564
+ patchId: z.string(),
3565
+ baseSha: z.string().refine(p => true),
3566
+ authorId: z.string().refine(p => true).nullable(),
3567
+ createdAt: z.string().datetime(),
3568
+ coreVersion: z.string().nullable(),
3569
+ sessionId: z.string().nullable()
3570
+ });
3571
+ const FSPatchBase = z.object({
3572
+ baseSha: z.string().refine(p => true),
3573
+ timestamp: z.string().datetime()
3574
+ });
3575
+
3576
+ /**
3577
+ * Something wrong with the store that a reader can see.
3578
+ *
3579
+ * Every one of these used to be invisible. Reporting them is the point: the
3580
+ * failure that motivated this rewrite was not that the store broke, it was that
3581
+ * breaking looked exactly like working.
3582
+ */
3583
+
3584
+ function patchesLogFile(patchesDir) {
3585
+ return path__default.join(patchesDir, PATCH_LOG_FILE_NAME);
3586
+ }
3587
+ function patchRepairLogFile(patchesDir) {
3588
+ return path__default.join(patchesDir, PATCH_REPAIR_LOG_FILE_NAME);
3589
+ }
3590
+ function patchDir(patchesDir, patchId) {
3591
+ return path__default.join(patchesDir, patchId);
3592
+ }
3593
+ function patchRecordFile(patchesDir, patchId) {
3594
+ return path__default.join(patchDir(patchesDir, patchId), "patch.json");
3595
+ }
3596
+ function patchBaseFile(patchesDir, patchId) {
3597
+ return path__default.join(patchDir(patchesDir, patchId), "base.json");
3598
+ }
3599
+ function patchBinaryFile(patchesDir, patchId, filePath) {
3600
+ return path__default.join(patchDir(patchesDir, patchId), "files", filePath, path__default.basename(filePath));
3601
+ }
3602
+ function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3603
+ return path__default.join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
3604
+ }
3605
+
3606
+ /** Names that live in the patches directory but are not patches. */
3607
+ const RESERVED_NAMES = new Set([PATCH_LOG_FILE_NAME, PATCH_REPAIR_LOG_FILE_NAME]);
3608
+ function readJsonFile(filePath, parser) {
3609
+ let raw;
3610
+ try {
3611
+ raw = fs.readFileSync(filePath, "utf-8");
3612
+ } catch (err) {
3613
+ return {
3614
+ error: err instanceof Error ? err.message : "unknown error"
3615
+ };
3616
+ }
3617
+ if (raw === "") {
3618
+ return {
3619
+ error: "the file is empty"
3620
+ };
3621
+ }
3622
+ let json;
3623
+ try {
3624
+ json = JSON.parse(raw);
3625
+ } catch (err) {
3626
+ return {
3627
+ error: `it is not valid JSON: ${err instanceof Error ? err.message : "unknown error"}`
3628
+ };
3629
+ }
3630
+ const parsed = parser.safeParse(json);
3631
+ if (!parsed.success) {
3632
+ return {
3633
+ error: fromError(parsed.error).toString()
3634
+ };
3635
+ }
3636
+ return {
3637
+ data: parsed.data
3638
+ };
3639
+ }
3640
+ function listPatchDirNames(patchesDir) {
3641
+ return fs.readdirSync(patchesDir, {
3642
+ withFileTypes: true
3643
+ }).filter(dirent => dirent.isDirectory() && !RESERVED_NAMES.has(dirent.name)).map(dirent => dirent.name);
3644
+ }
3645
+
3646
+ /**
3647
+ * Read the whole store: the order, the records, and everything wrong with it.
3648
+ *
3649
+ * One call, one answer. Callers that need only the ids and callers that need the
3650
+ * ops both use this, which is what stops them disagreeing.
3651
+ */
3652
+ function readPatchStore(patchesDir) {
3653
+ if (!fs.existsSync(patchesDir)) {
3654
+ return {
3655
+ status: "ok",
3656
+ entries: [],
3657
+ problems: []
3658
+ };
3659
+ }
3660
+ let dirNames;
3661
+ try {
3662
+ dirNames = listPatchDirNames(patchesDir);
3663
+ } catch (err) {
3664
+ return {
3665
+ status: "unreadable",
3666
+ message: `Could not list ${patchesDir}: ${err instanceof Error ? err.message : "unknown error"}`
3667
+ };
3668
+ }
3669
+ const problems = [];
3670
+ const logRes = readPatchLog(patchesLogFile(patchesDir));
3671
+ if (logRes.status === "unreadable") {
3672
+ return {
3673
+ status: "unreadable",
3674
+ message: logRes.message
3675
+ };
3676
+ }
3677
+ let logEntries;
3678
+ if (logRes.status === "absent") {
3679
+ if (dirNames.length === 0) {
3680
+ return {
3681
+ status: "ok",
3682
+ entries: [],
3683
+ problems: []
3684
+ };
3685
+ }
3686
+ logEntries = reconstructLogEntries(patchesDir, dirNames);
3687
+ if (logEntries.length > 0) {
3688
+ problems.push({
3689
+ type: "reconstructed-log",
3690
+ entryCount: logEntries.length
3691
+ });
3692
+ }
3693
+ } else {
3694
+ logEntries = logRes.entries;
3695
+ for (const problem of logRes.problems) {
3696
+ problems.push({
3697
+ type: "log",
3698
+ problem
3699
+ });
3700
+ }
3701
+ }
3702
+ const entries = [];
3703
+ const named = new Set();
3704
+ for (const logEntry of logEntries) {
3705
+ named.add(logEntry.patchId);
3706
+ const read = readPatchRecord(patchesDir, logEntry.patchId);
3707
+ if (read.error !== undefined) {
3708
+ problems.push({
3709
+ type: "unreadable-patch",
3710
+ name: logEntry.patchId,
3711
+ dir: patchDir(patchesDir, logEntry.patchId),
3712
+ message: read.error
3713
+ });
3714
+ continue;
3715
+ }
3716
+ const baseFile = patchBaseFile(patchesDir, logEntry.patchId);
3717
+ const baseRes = fs.existsSync(baseFile) ? readJsonFile(baseFile, FSPatchBase) : undefined;
3718
+ entries.push({
3719
+ patchId: logEntry.patchId,
3720
+ record: read.data,
3721
+ base: (baseRes === null || baseRes === void 0 ? void 0 : baseRes.data) ?? null
3722
+ });
3723
+ }
3724
+ for (const name of dirNames) {
3725
+ if (named.has(name)) {
3726
+ continue;
3727
+ }
3728
+ // Told apart by whether the directory holds a usable record. One is a patch
3729
+ // whose contents are lost and has to be reported; the other is a crash
3730
+ // leftover nothing ever read.
3731
+ const read = readPatchRecord(patchesDir, name);
3732
+ if (read.error !== undefined) {
3733
+ problems.push({
3734
+ type: "unreadable-patch",
3735
+ name,
3736
+ dir: path__default.join(patchesDir, name),
3737
+ message: read.error
3738
+ });
3739
+ } else {
3740
+ problems.push({
3741
+ type: "orphan-directory",
3742
+ name,
3743
+ dir: path__default.join(patchesDir, name)
3744
+ });
3745
+ }
3746
+ }
3747
+ return {
3748
+ status: "ok",
3749
+ entries,
3750
+ problems
3751
+ };
3752
+ }
3753
+
3754
+ /**
3755
+ * Read one patch directory, insisting it holds the patch it is named after.
3756
+ *
3757
+ * The name check is not a formality. A directory named after something other
3758
+ * than the record inside is how the previous layout worked - the name was the
3759
+ * record's PARENT - so this is what tells a store this version can use from one
3760
+ * it cannot.
3761
+ */
3762
+ function readPatchRecord(patchesDir, name) {
3763
+ const file = patchRecordFile(patchesDir, name);
3764
+ if (!fs.existsSync(file)) {
3765
+ return {
3766
+ error: `there is no ${file}`
3767
+ };
3768
+ }
3769
+ const res = readJsonFile(file, FSPatch);
3770
+ if (res.error !== undefined) {
3771
+ return {
3772
+ error: res.error
3773
+ };
3774
+ }
3775
+ if (res.data.patchId !== name) {
3776
+ return {
3777
+ error: `it holds ${res.data.patchId}, not the patch its directory is named after`
3778
+ };
3779
+ }
3780
+ return {
3781
+ data: res.data
3782
+ };
3783
+ }
3784
+
3785
+ /**
3786
+ * Rebuild the order from the records when the log itself is gone.
3787
+ *
3788
+ * `createdAt` is the only ordering left, and it is not a perfect one, so the
3789
+ * caller reports that the order was guessed rather than presenting it as fact.
3790
+ */
3791
+ function reconstructLogEntries(patchesDir, dirNames) {
3792
+ const entries = [];
3793
+ for (const name of dirNames) {
3794
+ const res = readJsonFile(path__default.join(patchesDir, name, "patch.json"), FSPatch);
3795
+ if (res.error !== undefined || res.data.patchId !== name) {
3796
+ continue;
3797
+ }
3798
+ entries.push({
3799
+ patchId: res.data.patchId,
3800
+ createdAt: res.data.createdAt,
3801
+ path: res.data.path
3802
+ });
3803
+ }
3804
+ entries.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3805
+ return entries;
3806
+ }
3807
+
3808
+ /** Write a patch record so that a reader sees all of it or none of it. */
3809
+ function writePatchRecord(patchesDir, patchId, record) {
3810
+ const dir = patchDir(patchesDir, patchId);
3811
+ fs.mkdirSync(dir, {
3812
+ recursive: true
3813
+ });
3814
+ const file = patchRecordFile(patchesDir, patchId);
3815
+ const tmpFile = `${file}.tmp-${process.pid}`;
3816
+ const fd = fs.openSync(tmpFile, "w");
3817
+ try {
3818
+ fs.writeSync(fd, JSON.stringify(record));
3819
+ fs.fsyncSync(fd);
3820
+ } finally {
3821
+ fs.closeSync(fd);
3822
+ }
3823
+ fs.renameSync(tmpFile, file);
3824
+ }
3825
+
3826
+ /**
3827
+ * Add a patch to the store.
3828
+ *
3829
+ * Record first, then the log line, and the order is the safety property: an
3830
+ * interrupted append leaves a directory nothing points at, which repair sweeps
3831
+ * up. The reverse order would leave the log naming a patch that is not there -
3832
+ * the exact state this whole rewrite exists to make unreachable.
3833
+ *
3834
+ * Callers must hold the patch lock.
3835
+ */
3836
+ function appendPatch(patchesDir, record) {
3837
+ const patchId = record.patchId;
3838
+ writePatchRecord(patchesDir, patchId, record);
3839
+ const entry = {
3840
+ patchId,
3841
+ createdAt: record.createdAt,
3842
+ path: record.path
3843
+ };
3844
+ appendPatchLogEntry(patchesLogFile(patchesDir), entry);
3845
+ return entry;
3846
+ }
3847
+ /**
3848
+ * Bring the store back to a state where the log and the directories agree.
3849
+ *
3850
+ * Safe in a way the old layout's repair could never be: the log is a flat list,
3851
+ * so dropping an entry does not orphan the entries after it. There is nothing to
3852
+ * re-link, which is why this can run unattended where re-parenting a chain could
3853
+ * not.
3854
+ *
3855
+ * A patch that cannot be read is removed rather than kept around to fail again
3856
+ * on every load. That does discard unpublished work, which is why
3857
+ * {@link RepairAction} carries the reason, why it is written to
3858
+ * `patches.repair.log`, and why the caller is expected to tell the person
3859
+ * editing.
3860
+ *
3861
+ * Callers must hold the patch lock.
3862
+ */
3863
+ function repairPatchStore(patchesDir, read) {
3864
+ const actions = [];
3865
+ const needsRewrite = read.problems.some(problem => problem.type === "unreadable-patch" || problem.type === "reconstructed-log" || problem.type === "log");
3866
+ for (const problem of read.problems) {
3867
+ if (problem.type === "unreadable-patch") {
3868
+ remove(problem.dir);
3869
+ actions.push({
3870
+ type: "removed-unreadable-patch",
3871
+ name: problem.name,
3872
+ because: problem.message
3873
+ });
3874
+ } else if (problem.type === "orphan-directory") {
3875
+ remove(problem.dir);
3876
+ actions.push({
3877
+ type: "removed-orphan-directory",
3878
+ name: problem.name
3879
+ });
3880
+ }
3881
+ }
3882
+ if (needsRewrite) {
3883
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.map(entry => ({
3884
+ patchId: entry.patchId,
3885
+ createdAt: entry.record.createdAt,
3886
+ path: entry.record.path
3887
+ })));
3888
+ actions.push({
3889
+ type: "rewrote-log",
3890
+ entryCount: read.entries.length
3891
+ });
3892
+ }
3893
+ if (actions.length > 0) {
3894
+ recordRepair(patchesDir, actions);
3895
+ }
3896
+ return actions;
3897
+ }
3898
+ function remove(dir) {
3899
+ try {
3900
+ fs.rmSync(dir, {
3901
+ recursive: true,
3902
+ force: true
3903
+ });
3904
+ } catch {
3905
+ // Left in place. It is inert either way - nothing reads a directory the log
3906
+ // does not name, and the log has already been rewritten without it.
3907
+ }
3908
+ }
3909
+
3910
+ /**
3911
+ * Leave a trail. Repair drops a person's unpublished edits, and doing that
3912
+ * without saying so anywhere durable is how you get someone certain they saved
3913
+ * something and no way to tell them what happened to it.
3914
+ */
3915
+ function recordRepair(patchesDir, actions) {
3916
+ const at = new Date().toISOString();
3917
+ const lines = actions.map(action => {
3918
+ if (action.type === "removed-unreadable-patch") {
3919
+ return `${at} removed ${action.name}: ${action.because}`;
3920
+ }
3921
+ if (action.type === "removed-orphan-directory") {
3922
+ return `${at} removed orphan directory ${action.name}`;
3923
+ }
3924
+ return `${at} rewrote the log with ${action.entryCount} entries`;
3925
+ });
3926
+ try {
3927
+ fs.appendFileSync(patchRepairLogFile(patchesDir), `${lines.join("\n")}\n`, "utf-8");
3928
+ } catch {
3929
+ // An audit trail that cannot be written is not a reason to fail the repair.
3930
+ }
3931
+ }
3932
+
3933
+ /**
3934
+ * Last resort: move the whole store aside and start empty.
3935
+ *
3936
+ * A rename, never a delete. What is being given up on here is someone's
3937
+ * unpublished work, and the least this can do is say where it went.
3938
+ *
3939
+ * Callers must hold the patch lock.
3940
+ */
3941
+ function resetPatchStore(patchesDir, reason) {
3942
+ if (!fs.existsSync(patchesDir)) {
3943
+ return {
3944
+ movedTo: patchesDir
3945
+ };
3946
+ }
3947
+ const movedTo = `${patchesDir}-corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
3948
+ try {
3949
+ fs.renameSync(patchesDir, movedTo);
3950
+ } catch (err) {
3951
+ return {
3952
+ error: `Could not move ${patchesDir} aside: ${err instanceof Error ? err.message : "unknown error"}`
3953
+ };
3954
+ }
3955
+ try {
3956
+ fs.mkdirSync(patchesDir, {
3957
+ recursive: true
3958
+ });
3959
+ fs.appendFileSync(patchRepairLogFile(patchesDir), `${new Date().toISOString()} reset the store (${reason}); the previous contents are in ${movedTo}\n`, "utf-8");
3960
+ } catch {
3961
+ // The rename is the part that mattered.
3962
+ }
3963
+ return {
3964
+ movedTo
3965
+ };
3966
+ }
3967
+
3968
+ /** One line per problem, for a log line or an API error a person has to act on. */
3969
+ function describePatchStoreProblems(problems) {
3970
+ return problems.map(problem => {
3971
+ switch (problem.type) {
3972
+ case "unreadable-patch":
3973
+ return `The change ${problem.name} could not be read: ${problem.message} (${problem.dir})`;
3974
+ case "orphan-directory":
3975
+ return `${problem.dir} is not listed in the log and is not being used.`;
3976
+ case "reconstructed-log":
3977
+ return `The log was missing, so the order of ${problem.entryCount} change(s) was reconstructed from their timestamps and may not be exact.`;
3978
+ case "log":
3979
+ return describeLogProblem(problem.problem);
3980
+ }
3981
+ });
3982
+ }
3983
+ function describeLogProblem(problem) {
3984
+ switch (problem.type) {
3985
+ case "missing-header":
3986
+ return `The log does not start with its header (it starts with ${JSON.stringify(problem.firstLine)}).`;
3987
+ case "unsupported-version":
3988
+ return `The log says ${JSON.stringify(problem.header)}, which this version of Val does not understand.`;
3989
+ case "unparseable-line":
3990
+ return `Line ${problem.lineNumber} of the log could not be read: ${JSON.stringify(problem.line)}`;
3991
+ case "duplicate-entry":
3992
+ return `The change ${problem.patchId} is listed twice in the log (line ${problem.lineNumber}).`;
3993
+ case "torn-final-line":
3994
+ return `The last line of the log was not finished being written and was ignored.`;
3995
+ }
3996
+ }
3997
+
3998
+ /**
3999
+ * A timed, cross-process lock over the patches directory.
4000
+ *
4001
+ * ## Why there is one at all
4002
+ *
4003
+ * Nothing serialized writes to the patch store before this. Two dev-server route
4004
+ * handlers, two browser tabs and the CLI all wrote to the same directory, and the
4005
+ * one guard that existed — a `mkdir` whose stated purpose was to throw when the
4006
+ * directory already existed — had its `EEXIST` swallowed by a bare `catch {}`, so
4007
+ * a colliding write silently overwrote the patch that was already there. A store
4008
+ * that loses a patch that way loses every patch chained behind it too.
4009
+ *
4010
+ * ## Why a file, and why timed
4011
+ *
4012
+ * A real lock manager is more than this problem needs: the contenders are a
4013
+ * handful of processes on one machine, holding it for milliseconds. What that
4014
+ * does need is to survive a holder that dies — `kill -9` on a dev server, a
4015
+ * laptop lid, a crashed test — without leaving the store permanently unwritable.
4016
+ * Hence an expiry: a lock nobody renews stops being a lock.
4017
+ *
4018
+ * The file is plain `key: value` text on purpose. When something is stuck, the
4019
+ * question is always "who is holding it and since when", and `cat .val/patches.lock`
4020
+ * should answer it without a tool.
4021
+ *
4022
+ * ## The takeover race
4023
+ *
4024
+ * Two processes can decide the same lock is stale at the same moment, and a blind
4025
+ * `unlink` would then let the second one delete the FIRST one's fresh lock. So a
4026
+ * stale lock is only removed after being read twice, unchanged, across a poll
4027
+ * interval, and the `owner` nonce is what "unchanged" means. Whoever wins the
4028
+ * `wx` create afterwards is unique regardless, because that create is atomic —
4029
+ * the double read is what stops a winner being deleted a moment after winning.
4030
+ */
4031
+
4032
+ const PATCH_LOCK_FILE_NAME = "patches.lock";
4033
+ const DEFAULT_TIMEOUT_MS = 10_000;
4034
+ const DEFAULT_POLL_MS = 25;
4035
+
4036
+ /**
4037
+ * Locks this process is currently holding, released on exit.
4038
+ *
4039
+ * A best-effort courtesy: it turns the common crash — an unhandled throw, a
4040
+ * ctrl-C — into an immediately reusable store instead of one that waits out a
4041
+ * TTL. The TTL is still what makes correctness not depend on this running.
4042
+ */
4043
+ const heldLocks = new Set();
4044
+ let exitHookInstalled = false;
4045
+ function installExitHook() {
4046
+ if (exitHookInstalled) {
4047
+ return;
4048
+ }
4049
+ exitHookInstalled = true;
4050
+ process.once("exit", () => {
4051
+ heldLocks.forEach(lock => {
4052
+ try {
4053
+ lock.release();
4054
+ } catch {
4055
+ // Nothing useful can be done during exit.
4056
+ }
4057
+ });
4058
+ });
4059
+ }
4060
+ function formatPatchLockInfo(info) {
4061
+ return [`owner: ${info.owner}`, `pid: ${info.pid}`, `host: ${info.host}`, `op: ${info.op}`, `acquired: ${info.acquiredAt}`, `expires: ${info.expiresAt}`, ""].join("\n");
4062
+ }
4063
+ function parsePatchLockInfo(raw) {
4064
+ const fields = {};
4065
+ for (const line of raw.split("\n")) {
4066
+ const separator = line.indexOf(":");
4067
+ if (separator === -1) {
4068
+ continue;
4069
+ }
4070
+ fields[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
4071
+ }
4072
+ const {
4073
+ owner,
4074
+ pid,
4075
+ host,
4076
+ op,
4077
+ acquired,
4078
+ expires
4079
+ } = fields;
4080
+ if (!owner || !acquired || !expires) {
4081
+ return null;
4082
+ }
4083
+ const parsedPid = Number.parseInt(pid ?? "", 10);
4084
+ return {
4085
+ owner,
4086
+ pid: Number.isNaN(parsedPid) ? -1 : parsedPid,
4087
+ host: host ?? "",
4088
+ op: op ?? "",
4089
+ acquiredAt: acquired,
4090
+ expiresAt: expires
4091
+ };
4092
+ }
4093
+ function readRaw(lockFilePath) {
4094
+ try {
4095
+ return fs.readFileSync(lockFilePath, "utf-8");
4096
+ } catch {
4097
+ return null;
4098
+ }
4099
+ }
4100
+ function isExpired(info, nowMs) {
4101
+ // An unparseable lock file is treated as expired. It is either a half-written
4102
+ // acquisition or something that is not a lock at all, and neither should be
4103
+ // able to wedge the store forever.
4104
+ if (info === null) {
4105
+ return true;
4106
+ }
4107
+ const expiresAt = Date.parse(info.expiresAt);
4108
+ if (Number.isNaN(expiresAt)) {
4109
+ return true;
4110
+ }
4111
+ return expiresAt <= nowMs;
4112
+ }
4113
+ function describeHolder(info) {
4114
+ if (info === null) {
4115
+ return "the lock file could not be read";
4116
+ }
4117
+ return `held by pid ${info.pid} on ${info.host || "unknown host"} for "${info.op}" since ${info.acquiredAt} (expires ${info.expiresAt})`;
4118
+ }
4119
+ function sleep(ms) {
4120
+ return new Promise(resolve => setTimeout(resolve, ms));
4121
+ }
4122
+
4123
+ /**
4124
+ * Try to create the lock file exclusively.
4125
+ *
4126
+ * `wx` is `O_CREAT | O_EXCL`: the create either happens or reports EEXIST, with
4127
+ * no window in between. Everything else in this module exists to handle the
4128
+ * EEXIST case; this is the part that actually provides mutual exclusion.
4129
+ */
4130
+ function tryCreate(lockFilePath, info) {
4131
+ let fd;
4132
+ try {
4133
+ fd = fs.openSync(lockFilePath, "wx");
4134
+ } catch (err) {
4135
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") {
4136
+ return "exists";
4137
+ }
4138
+ return {
4139
+ error: err instanceof Error ? err.message : "unknown error"
4140
+ };
4141
+ }
4142
+ try {
4143
+ fs.writeSync(fd, formatPatchLockInfo(info));
4144
+ fs.fsyncSync(fd);
4145
+ } finally {
4146
+ fs.closeSync(fd);
4147
+ }
4148
+ return "created";
4149
+ }
4150
+ async function acquirePatchLock(lockFilePath, options) {
4151
+ const now = options.now ?? Date.now;
4152
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4153
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
4154
+ const deadline = now() + timeoutMs;
4155
+ try {
4156
+ fs.mkdirSync(path__default.dirname(lockFilePath), {
4157
+ recursive: true
4158
+ });
4159
+ } catch (err) {
4160
+ return {
4161
+ status: "error",
4162
+ message: `Could not create the directory for ${lockFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
4163
+ };
4164
+ }
4165
+ let lastSeenHolder = null;
4166
+ for (;;) {
4167
+ const acquiredAt = now();
4168
+ const info = {
4169
+ owner: crypto$1.randomUUID(),
4170
+ pid: process.pid,
4171
+ host: os.hostname(),
4172
+ op: options.op,
4173
+ acquiredAt: new Date(acquiredAt).toISOString(),
4174
+ expiresAt: new Date(acquiredAt + options.ttlMs).toISOString()
4175
+ };
4176
+ const created = tryCreate(lockFilePath, info);
4177
+ if (created === "created") {
4178
+ const lock = createLock(lockFilePath, info, options.ttlMs, now);
4179
+ installExitHook();
4180
+ heldLocks.add(lock);
4181
+ return {
4182
+ status: "acquired",
4183
+ lock
4184
+ };
4185
+ }
4186
+ if (created !== "exists") {
4187
+ return {
4188
+ status: "error",
4189
+ message: `Could not create ${lockFilePath}: ${created.error}`
4190
+ };
4191
+ }
4192
+ const rawBefore = readRaw(lockFilePath);
4193
+ if (rawBefore === null) {
4194
+ // Released between the failed create and the read. Go straight round again.
4195
+ continue;
4196
+ }
4197
+ lastSeenHolder = parsePatchLockInfo(rawBefore);
4198
+ if (isExpired(lastSeenHolder, now())) {
4199
+ // Read twice across a poll interval before removing it: an unchanged stale
4200
+ // file is one nobody is renewing, and requiring the second read is what
4201
+ // stops two processes both deciding to clear it and the loser wiping the
4202
+ // winner's brand-new lock.
4203
+ await sleep(pollMs);
4204
+ const rawAfter = readRaw(lockFilePath);
4205
+ if (rawAfter === rawBefore && isExpired(lastSeenHolder, now())) {
4206
+ try {
4207
+ fs.unlinkSync(lockFilePath);
4208
+ } catch {
4209
+ // Someone else got there first, which is the outcome we wanted anyway.
3305
4210
  }
3306
4211
  }
3307
- } else {
3308
- lastNonDeletedPatchIndex = i;
4212
+ continue;
4213
+ }
4214
+ if (now() >= deadline) {
4215
+ return {
4216
+ status: "busy",
4217
+ holder: lastSeenHolder,
4218
+ message: `Timed out after ${timeoutMs}ms waiting for the Val patch lock at ${lockFilePath}: ${describeHolder(lastSeenHolder)}`
4219
+ };
3309
4220
  }
4221
+ await sleep(pollMs);
3310
4222
  }
3311
- return {
3312
- changedPatches
4223
+ }
4224
+ function createLock(lockFilePath, initialInfo, defaultTtlMs, now) {
4225
+ let info = initialInfo;
4226
+ let released = false;
4227
+ const stillOurs = () => {
4228
+ var _parsePatchLockInfo;
4229
+ const raw = readRaw(lockFilePath);
4230
+ if (raw === null) {
4231
+ return false;
4232
+ }
4233
+ return ((_parsePatchLockInfo = parsePatchLockInfo(raw)) === null || _parsePatchLockInfo === void 0 ? void 0 : _parsePatchLockInfo.owner) === info.owner;
4234
+ };
4235
+ const lock = {
4236
+ get info() {
4237
+ return info;
4238
+ },
4239
+ renew(ttlMs) {
4240
+ if (released || !stillOurs()) {
4241
+ return false;
4242
+ }
4243
+ const next = {
4244
+ ...info,
4245
+ expiresAt: new Date(now() + (ttlMs ?? defaultTtlMs)).toISOString()
4246
+ };
4247
+ const fd = fs.openSync(lockFilePath, "w");
4248
+ try {
4249
+ fs.writeSync(fd, formatPatchLockInfo(next));
4250
+ fs.fsyncSync(fd);
4251
+ } finally {
4252
+ fs.closeSync(fd);
4253
+ }
4254
+ info = next;
4255
+ return true;
4256
+ },
4257
+ release() {
4258
+ if (released) {
4259
+ return;
4260
+ }
4261
+ released = true;
4262
+ heldLocks.delete(lock);
4263
+ // Only ours. If the lock expired and someone else took it, the file now
4264
+ // belongs to them and deleting it would hand the store to a third party.
4265
+ if (stillOurs()) {
4266
+ try {
4267
+ fs.unlinkSync(lockFilePath);
4268
+ } catch {
4269
+ // Already gone.
4270
+ }
4271
+ }
4272
+ }
3313
4273
  };
4274
+ return lock;
4275
+ }
4276
+
4277
+ /**
4278
+ * Run `fn` while holding the lock, and always give it back.
4279
+ *
4280
+ * The only shape callers should use: a `release()` that a throw can skip is how
4281
+ * a store ends up locked by a process that has long since moved on.
4282
+ */
4283
+ async function withPatchLock(lockFilePath, options, fn) {
4284
+ const acquired = await acquirePatchLock(lockFilePath, options);
4285
+ if (acquired.status !== "acquired") {
4286
+ return acquired;
4287
+ }
4288
+ try {
4289
+ return {
4290
+ status: "ok",
4291
+ value: await fn(acquired.lock)
4292
+ };
4293
+ } finally {
4294
+ acquired.lock.release();
4295
+ }
3314
4296
  }
3315
4297
 
3316
4298
  async function uploadRemoteFile(contentHost, project, bucket, fileHash, fileExt, fileBuffer, auth) {
@@ -3381,6 +4363,20 @@ class ValOpsFS extends ValOps {
3381
4363
  * content lives behind a thunk that `JSON.stringify` drops).
3382
4364
  */
3383
4365
 
4366
+ /**
4367
+ * Unpublished changes repair threw away, waiting to be told to somebody.
4368
+ *
4369
+ * Held here and drained by the next `getStat`, because stat is the channel
4370
+ * that always flows. The case worth reporting is a repair that removed
4371
+ * EVERYTHING - which is what an old store looks like - and then there is
4372
+ * nothing left for the studio to fetch, so a notice riding on `fetchPatches`
4373
+ * would never be collected. Drained rather than kept, because this has to be
4374
+ * said once: a permanent flag would put a toast on the screen forever.
4375
+ *
4376
+ * Held in memory only: a restart loses it, and that is the right trade. The
4377
+ * durable record is `patches.repair.log`.
4378
+ */
4379
+ removedPatchNotices = [];
3384
4380
  async onInit() {
3385
4381
  // do nothing
3386
4382
  }
@@ -3474,14 +4470,28 @@ class ValOpsFS extends ValOps {
3474
4470
  const serializedSchemas = Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path, serializeSchemaSafely(schema)]));
3475
4471
  const currentJsonEntriesSha = this.jsonEntryFilesFingerprint.compute(serializedSchemas);
3476
4472
  const jsonEntryFilePaths = this.jsonEntryFilesFingerprint.entryFilePaths(serializedSchemas);
3477
- const patchData = await this.readPatches();
3478
- const patches = [];
3479
- // TODO: use proper patch sequences when available:
3480
- for (const [patchId] of Object.entries(patchData.patches).sort(([, a], [, b]) => {
3481
- return a.createdAt.localeCompare(b.createdAt, undefined);
3482
- })) {
3483
- patches.push(patchId);
4473
+
4474
+ // The SAME read `fetchPatches` delivers from. Announcing out of one source
4475
+ // and delivering out of another is what let this store tell a studio about
4476
+ // 410 unpublished changes and then hand over 359 of them, with nothing in
4477
+ // between reporting that anything was wrong.
4478
+ const announceRes = await this.readStore();
4479
+ if (announceRes.status === "error") {
4480
+ return {
4481
+ type: "error",
4482
+ error: {
4483
+ message: announceRes.message
4484
+ }
4485
+ };
3484
4486
  }
4487
+ const patches = announceRes.entries.map(entry => entry.patchId);
4488
+ // Drained here, on the channel that always flows. A repair that removed
4489
+ // everything leaves the studio nothing to fetch, so a notice riding on
4490
+ // `GET /patches` would sit here unread.
4491
+ const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4492
+ const removedNotice = removed.length > 0 ? {
4493
+ removed
4494
+ } : {};
3485
4495
  // something changed: return immediately
3486
4496
  const didChange = !params ||
3487
4497
  // An entry file changed on disk: nothing else here can see that, since a
@@ -3496,6 +4506,7 @@ class ValOpsFS extends ValOps {
3496
4506
  schemaSha: currentSchemaSha,
3497
4507
  sourcesSha: currentSourcesSha,
3498
4508
  patches,
4509
+ ...removedNotice,
3499
4510
  jsonEntriesSha: currentJsonEntriesSha
3500
4511
  };
3501
4512
  }
@@ -3620,6 +4631,7 @@ class ValOpsFS extends ValOps {
3620
4631
  schemaSha: currentSchemaSha,
3621
4632
  sourcesSha: currentSourcesSha,
3622
4633
  patches,
4634
+ ...removedNotice,
3623
4635
  jsonEntriesSha: currentJsonEntriesSha
3624
4636
  };
3625
4637
  } catch (err) {
@@ -3639,134 +4651,152 @@ class ValOpsFS extends ValOps {
3639
4651
  };
3640
4652
  }
3641
4653
  }
3642
- async readPatches(includes) {
3643
- const patchesCacheDir = this.getPatchesDir();
3644
- let patchJsonFiles = [];
3645
- if (!this.host.directoryExists || this.host.directoryExists && this.host.directoryExists(patchesCacheDir)) {
3646
- patchJsonFiles = this.host.readDirectory(patchesCacheDir, ["patch.json"], [], []);
3647
- }
3648
- const patches = {};
3649
- const errors = [];
3650
- const parsedUnsortedFsPatches = patchJsonFiles.map(file => path__default.basename(path__default.dirname(file))).map(patchDir => [patchDir, this.parseJsonFile(this.getPatchFilePath(patchDir), FSPatch), this.host.fileExists(this.getPatchBaseFile(patchDir)) ? this.parseJsonFile(this.getPatchBaseFile(patchDir), FSPatchBase) : undefined]);
3651
- parsedUnsortedFsPatches.forEach(([dir, parsedPatch, parsedBase]) => {
3652
- if (parsedPatch.error) {
3653
- errors.push({
3654
- ...parsedPatch.error,
3655
- parentPatchId: dir
3656
- });
3657
- } else if (parsedBase && parsedBase.error) {
3658
- errors.push({
3659
- ...parsedBase.error,
3660
- parentPatchId: dir
3661
- });
3662
- } else {
3663
- const patchId = parsedPatch.data.patchId;
3664
- if (includes && includes.length > 0 && !includes.includes(patchId)) {
3665
- return;
3666
- }
3667
- patches[patchId] = {
3668
- path: parsedPatch.data.path,
3669
- patch: parsedPatch.data.patch,
3670
- parentRef: parsedPatch.data.parentRef,
3671
- baseSha: parsedPatch.data.baseSha,
3672
- createdAt: parsedPatch.data.createdAt,
3673
- authorId: parsedPatch.data.authorId,
3674
- appliedAt: null
3675
- };
3676
- }
3677
- });
3678
4654
 
3679
- // If there are patches, but no head. error
3680
- if (Object.keys(errors).length > 0) {
4655
+ /**
4656
+ * The one read every other read comes out of.
4657
+ *
4658
+ * `getStat` announces from this and `fetchPatches` delivers from this, so the
4659
+ * two cannot disagree. They used to: `getStat` counted the directories on disk
4660
+ * while `fetchPatches` walked the parent links between them, and when a single
4661
+ * record went missing the first said 410 and the second said 359 — with no
4662
+ * error anywhere, because a walk that runs out of links just stops.
4663
+ *
4664
+ * Anything wrong is reported, then repaired, and reset only if repair does not
4665
+ * settle it. In that order, and never silently.
4666
+ */
4667
+ async readStore() {
4668
+ const read = readPatchStore(this.getPatchesDir());
4669
+ if (read.status === "unreadable") {
3681
4670
  return {
3682
- patches,
3683
- errors
4671
+ status: "error",
4672
+ message: read.message
3684
4673
  };
3685
4674
  }
3686
- return {
3687
- patches
3688
- };
3689
- }
3690
- getParentPatchIdFromParentRef(parentRef) {
3691
- return parentRef.type === "head" ? "head" : parentRef.patchId;
4675
+ if (read.problems.length === 0) {
4676
+ return {
4677
+ status: "ok",
4678
+ entries: read.entries
4679
+ };
4680
+ }
4681
+ return this.repairStore(read);
3692
4682
  }
3693
- async fetchPatches(filters) {
3694
- const fetchPatchesRes = await this.fetchPatchesFromFS(!!filters.excludePatchOps);
3695
- const sortedPatches = this.createPatchChain(fetchPatchesRes.patches).map(patchData => {
3696
- if (filters.excludePatchOps) {
4683
+
4684
+ /**
4685
+ * Report, repair, and reset only if repair did not take.
4686
+ *
4687
+ * Reached only when something is actually wrong, so the healthy path — which
4688
+ * is every stat poll — never touches the lock.
4689
+ */
4690
+ async repairStore(read) {
4691
+ const patchesDir = this.getPatchesDir();
4692
+ for (const problem of describePatchStoreProblems(read.problems)) {
4693
+ console.warn("Val: something is wrong with the patch store.", problem);
4694
+ }
4695
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4696
+ ttlMs: 30_000,
4697
+ op: "repair the patch store"
4698
+ }, () => {
4699
+ // Read again under the lock. What was seen above was seen without it, so
4700
+ // a write may have landed since — and repairing against a stale picture
4701
+ // is how the old delete path destroyed live patches.
4702
+ const current = readPatchStore(patchesDir);
4703
+ if (current.status !== "ok") {
3697
4704
  return {
3698
- ...patchData,
3699
- patch: undefined
4705
+ status: "error",
4706
+ message: current.message
3700
4707
  };
3701
4708
  }
3702
- return patchData;
3703
- }).filter(patchData => {
3704
- if (filters.patchIds && filters.patchIds.length > 0) {
3705
- return filters.patchIds.includes(patchData.patchId);
4709
+ if (current.problems.length === 0) {
4710
+ return {
4711
+ status: "ok",
4712
+ entries: current.entries
4713
+ };
3706
4714
  }
3707
- return true;
4715
+ for (const action of repairPatchStore(patchesDir, current)) {
4716
+ if (action.type === "removed-unreadable-patch") {
4717
+ console.warn(`Val: removed the unpublished change ${action.name} because ${action.because}. See ${patchesDir}/${PATCH_REPAIR_LOG_FILE_NAME}.`);
4718
+ // Queued rather than only logged: this is someone's unpublished
4719
+ // work being thrown away, and a server console is not somewhere the
4720
+ // person editing is looking.
4721
+ this.removedPatchNotices.push({
4722
+ patchId: action.name,
4723
+ reason: action.because
4724
+ });
4725
+ } else if (action.type === "removed-orphan-directory") {
4726
+ // Not queued. Nothing ever read this - it is a record whose log line
4727
+ // never landed - so there is no edit to tell anyone about.
4728
+ console.warn(`Val: removed the unused patch directory ${action.name}.`);
4729
+ }
4730
+ }
4731
+ const after = readPatchStore(patchesDir);
4732
+ if (after.status === "ok" && after.problems.length === 0) {
4733
+ return {
4734
+ status: "ok",
4735
+ entries: after.entries
4736
+ };
4737
+ }
4738
+ // Repair did not settle it, so the store is not something anyone can
4739
+ // describe any more. Move it aside — a rename, never a delete — and say
4740
+ // where it went.
4741
+ const reset = resetPatchStore(patchesDir, "it could not be repaired automatically");
4742
+ if ("error" in reset) {
4743
+ return {
4744
+ status: "error",
4745
+ message: reset.error
4746
+ };
4747
+ }
4748
+ console.warn(`Val: the patch store could not be repaired, so it was reset. The previous contents are in ${reset.movedTo}.`);
4749
+ return {
4750
+ status: "ok",
4751
+ entries: []
4752
+ };
3708
4753
  });
3709
- return {
3710
- patches: sortedPatches,
3711
- errors: fetchPatchesRes.errors
3712
- };
3713
- }
3714
- async fetchPatchesFromFS(excludePatchOps) {
3715
- const patches = {};
3716
- const {
3717
- errors,
3718
- patches: allPatches
3719
- } = await this.readPatches();
3720
- for (const [patchIdS, patch] of Object.entries(allPatches)) {
3721
- const patchId = patchIdS;
3722
- patches[patchId] = {
3723
- patch: excludePatchOps ? undefined : patch.patch,
3724
- parentRef: patch.parentRef,
3725
- path: patch.path,
3726
- baseSha: patch.baseSha,
3727
- createdAt: patch.createdAt,
3728
- authorId: patch.authorId,
3729
- appliedAt: patch.appliedAt
4754
+ if (locked.status !== "ok") {
4755
+ // Someone else holds the lock, quite possibly to run this same repair.
4756
+ // Serving what was read is safe — those entries are the ones that ARE
4757
+ // readable — and the next read tries again.
4758
+ console.warn(`Val: could not repair the patch store now. ${locked.message}`);
4759
+ return {
4760
+ status: "ok",
4761
+ entries: read.entries
3730
4762
  };
3731
4763
  }
3732
- if (errors && errors.length > 0) {
4764
+ return locked.value;
4765
+ }
4766
+ async fetchPatches(filters) {
4767
+ const storeRes = await this.readStore();
4768
+ if (storeRes.status === "error") {
4769
+ // Surfaced, not returned as an empty list. An empty answer to "what is
4770
+ // pending" is indistinguishable from "nothing is pending", and the studio
4771
+ // would render published content over unpublished edits without a word.
4772
+ const none = [];
3733
4773
  return {
3734
- patches,
3735
- errors
4774
+ patches: none,
4775
+ error: {
4776
+ message: storeRes.message
4777
+ }
4778
+ // The cast is unavoidable: the return type is conditional on a generic
4779
+ // that TypeScript cannot narrow from a value.
3736
4780
  };
3737
4781
  }
4782
+ const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
4783
+ const patches = storeRes.entries.filter(entry => requested === null || requested.has(entry.patchId)).map(entry => ({
4784
+ patchId: entry.patchId,
4785
+ path: entry.record.path,
4786
+ patch: filters.excludePatchOps ? undefined : entry.record.patch,
4787
+ createdAt: entry.record.createdAt,
4788
+ authorId: entry.record.authorId,
4789
+ baseSha: entry.record.baseSha,
4790
+ // Publishing deletes the whole store, so in fs mode a patch that is
4791
+ // still here has not been applied. `base.json` is read for its parse
4792
+ // errors, not for this.
4793
+ appliedAt: null
4794
+ }));
3738
4795
  return {
3739
4796
  patches
3740
4797
  };
3741
4798
  }
3742
4799
 
3743
- // #region createPatchChain
3744
- createPatchChain(unsortedPatchRecord) {
3745
- var _Object$entries$find;
3746
- // TODO: Error handling
3747
- const nextPatch = {};
3748
- Object.keys(unsortedPatchRecord).forEach(patchId => {
3749
- const patch = unsortedPatchRecord[patchId];
3750
- if (patch.parentRef.type === "head") {
3751
- nextPatch["head"] = patchId;
3752
- } else {
3753
- nextPatch[patch.parentRef.patchId] = patchId;
3754
- }
3755
- });
3756
- const sortedPatches = [];
3757
- let nextPatchId = (_Object$entries$find = Object.entries(unsortedPatchRecord).find(([, patch]) => patch.parentRef.type === "head")) === null || _Object$entries$find === void 0 ? void 0 : _Object$entries$find[0];
3758
- while (!!nextPatchId && nextPatchId in unsortedPatchRecord) {
3759
- const patch = unsortedPatchRecord[nextPatchId];
3760
- delete patch["parentRef"];
3761
- sortedPatches.push({
3762
- ...patch,
3763
- patchId: nextPatchId
3764
- });
3765
- nextPatchId = nextPatch[nextPatchId];
3766
- }
3767
- return sortedPatches;
3768
- }
3769
-
3770
4800
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3771
4801
  parseJsonFile(filePath, parser) {
3772
4802
  if (!this.host.fileExists(filePath)) {
@@ -3841,53 +4871,60 @@ class ValOpsFS extends ValOps {
3841
4871
  };
3842
4872
  }
3843
4873
  }
3844
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
3845
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
3846
- try {
3847
- const baseSha = await this.getBaseSha();
3848
- const data = {
3849
- patch,
3850
- patchId,
3851
- parentRef,
3852
- path,
3853
- authorId,
3854
- sessionId,
3855
- baseSha,
3856
- coreVersion: Internal.VERSION.core,
3857
- createdAt: new Date().toISOString()
3858
- };
3859
- const headParentPatchId = "head";
3860
- if (patchDir !== headParentPatchId && !this.host.fileExists(this.getPatchFilePath(headParentPatchId))) {
3861
- console.error("Val: out-of-band patch detected.", this.getPatchFilePath(headParentPatchId));
3862
- return result.err({
3863
- errorType: "patch-head-conflict"
3864
- });
3865
- }
3866
- const writeRes = this.host.tryWriteUf8File(this.getPatchFilePath(patchDir), JSON.stringify(data));
3867
- if (writeRes.type === "error") {
4874
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
4875
+ const patchesDir = this.getPatchesDir();
4876
+ const record = {
4877
+ patch,
4878
+ patchId,
4879
+ path,
4880
+ authorId,
4881
+ sessionId,
4882
+ baseSha: await this.getBaseSha(),
4883
+ coreVersion: Internal.VERSION.core,
4884
+ createdAt: new Date().toISOString()
4885
+ };
4886
+ /*
4887
+ * `parentRef` is ignored here, and that is the point of the log.
4888
+ *
4889
+ * It used to be the directory name, which is how a client working from a
4890
+ * stale view could write a patch whose parent had never landed and strand
4891
+ * every patch behind it. With the order held in one append-only list the
4892
+ * server decides where a patch goes: it goes last. There is no parent to
4893
+ * name, nothing to point at, and so nothing that can point at nothing.
4894
+ *
4895
+ * The parameter stays because `ValOpsHttp` genuinely needs it — the content
4896
+ * api runs its own chain and is told the parent — but in fs mode a stale
4897
+ * parent is not an error to refuse, it is a fact with no consequences.
4898
+ *
4899
+ * What that gives up is early detection of a patch computed against a
4900
+ * different state: two tabs editing the same array can produce an op that no
4901
+ * longer fits. That is caught where it actually shows — applying the patch —
4902
+ * and handled by dropping it and saying so, rather than by refusing writes
4903
+ * up front and making every interleaved edit a round trip.
4904
+ */
4905
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4906
+ ttlMs: 10_000,
4907
+ op: "PUT /patches"
4908
+ }, () => {
4909
+ try {
4910
+ appendPatch(patchesDir, record);
4911
+ } catch (err) {
3868
4912
  return result.err({
3869
4913
  errorType: "other",
3870
- error: writeRes.error,
3871
- message: "Failed to write patch file"
4914
+ message: `Failed to write the patch: ${err instanceof Error ? err.message : "unknown error"}`
3872
4915
  });
3873
4916
  }
3874
4917
  return result.ok({
3875
4918
  patchId
3876
4919
  });
3877
- } catch (err) {
3878
- if (err instanceof Error) {
3879
- return result.err({
3880
- errorType: "other",
3881
- error: err,
3882
- message: err.message
3883
- });
3884
- }
4920
+ });
4921
+ if (locked.status !== "ok") {
3885
4922
  return result.err({
3886
4923
  errorType: "other",
3887
- error: err,
3888
- message: "Unknown error"
4924
+ message: locked.message
3889
4925
  });
3890
4926
  }
4927
+ return locked.value;
3891
4928
  }
3892
4929
  async getSourceFile(path) {
3893
4930
  const filePath = path__default.join(this.rootDir, path);
@@ -3924,10 +4961,14 @@ class ValOpsFS extends ValOps {
3924
4961
  };
3925
4962
  }
3926
4963
  }
3927
- async saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, data, _type, metadata) {
3928
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
3929
- const patchFilePath = this.getBinaryFilePath(filePath, patchDir);
3930
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDir);
4964
+ async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4965
+ // Keyed by the patch's own id, so the parent is not needed and is not asked
4966
+ // for. Uploads arrive before the patch record does, which is fine: the
4967
+ // directory sits there unreferenced until the log line that names it lands,
4968
+ // and repair sweeps it up if that never happens.
4969
+ const patchesDir = this.getPatchesDir();
4970
+ const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
4971
+ const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
3931
4972
  try {
3932
4973
  if (data === null) {
3933
4974
  this.host.deleteFile(patchFilePath);
@@ -3967,15 +5008,7 @@ class ValOpsFS extends ValOps {
3967
5008
  }
3968
5009
  }
3969
5010
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
3970
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
3971
- if (result.isErr(patchDirRes)) {
3972
- return {
3973
- errors: [{
3974
- message: "Failed to get patch dir from patch id"
3975
- }]
3976
- };
3977
- }
3978
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDirRes.value);
5011
+ const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
3979
5012
  if (!this.host.fileExists(metadataFilePath)) {
3980
5013
  return {
3981
5014
  errors: [{
@@ -4011,95 +5044,132 @@ class ValOpsFS extends ValOps {
4011
5044
  };
4012
5045
  }
4013
5046
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
4014
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
4015
- if (!result.isOk(patchDirRes)) {
4016
- return null;
4017
- }
4018
- const absPath = this.getBinaryFilePath(filePath, patchDirRes.value);
5047
+ // Straight from the id. This used to read and parse every patch on disk to
5048
+ // work out which directory the file was under, on every single image request.
5049
+ const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
4019
5050
  if (!this.host.fileExists(absPath)) {
4020
5051
  return null;
4021
5052
  }
4022
5053
  return this.host.readBinaryFile(absPath);
4023
5054
  }
4024
5055
  async deletePatches(patchIds) {
4025
- const deleted = [];
4026
- const patchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4027
- if (result.isErr(patchDirMapRes)) {
5056
+ const patchesDir = this.getPatchesDir();
5057
+ const requested = new Set(patchIds);
5058
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5059
+ ttlMs: 30_000,
5060
+ op: "DELETE /patches"
5061
+ }, () => {
5062
+ const read = readPatchStore(patchesDir);
5063
+ if (read.status !== "ok") {
5064
+ return {
5065
+ status: "error",
5066
+ message: read.message
5067
+ };
5068
+ }
5069
+ /*
5070
+ * Deleting is now dropping lines from a list.
5071
+ *
5072
+ * It used to be a three-step re-link per surviving patch — rewrite a
5073
+ * record, remove the destination directory, rename another one over it —
5074
+ * with the plan computed from a snapshot taken before hundreds of file
5075
+ * reads, every failure swallowed by a `console.error`, and one step that
5076
+ * unconditionally removed whatever occupied the destination. An
5077
+ * interruption anywhere in it stranded every patch after the hole.
5078
+ */
5079
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.filter(entry => !requested.has(entry.patchId)).map(entry => ({
5080
+ patchId: entry.patchId,
5081
+ createdAt: entry.record.createdAt,
5082
+ path: entry.record.path
5083
+ })));
5084
+ // The log first, then the directories. A crash in between leaves
5085
+ // directories nothing names, which is inert and swept up on the next
5086
+ // read; the other order would leave the log naming patches that are gone.
5087
+ const deleted = [];
5088
+ const errors = {};
5089
+ for (const patchId of patchIds) {
5090
+ try {
5091
+ fs.rmSync(patchDir(patchesDir, patchId), {
5092
+ recursive: true,
5093
+ force: true
5094
+ });
5095
+ deleted.push(patchId);
5096
+ } catch (err) {
5097
+ // Reported. This endpoint used to answer "deleted" unconditionally —
5098
+ // `deleted` and `errors` were never written to at all — so a delete
5099
+ // that failed looked exactly like one that worked.
5100
+ errors[patchId] = {
5101
+ message: `Could not remove the files for ${patchId}: ${err instanceof Error ? err.message : "unknown error"}`
5102
+ };
5103
+ }
5104
+ }
5105
+ return {
5106
+ status: "ok",
5107
+ deleted,
5108
+ errors
5109
+ };
5110
+ });
5111
+ if (locked.status !== "ok") {
5112
+ return {
5113
+ error: {
5114
+ message: locked.message
5115
+ }
5116
+ };
5117
+ }
5118
+ if (locked.value.status === "error") {
4028
5119
  return {
4029
5120
  error: {
4030
- message: "Failed to get patch dir map"
5121
+ message: locked.value.message
4031
5122
  }
4032
5123
  };
4033
5124
  }
4034
- const currentPatches = this.createPatchChain((await this.fetchPatchesFromFS(false)).patches);
4035
- this.updateOrderedPatches(computeChangedPatchParentRefs(currentPatches, patchIds), patchDirMapRes.value, patchIds);
5125
+ const {
5126
+ deleted,
5127
+ errors
5128
+ } = locked.value;
5129
+ if (Object.keys(errors).length > 0) {
5130
+ return {
5131
+ deleted,
5132
+ errors
5133
+ };
5134
+ }
4036
5135
  return {
4037
5136
  deleted
4038
5137
  };
4039
5138
  }
4040
5139
  async deleteAllPatches() {
4041
- const patchesCacheDir = this.getPatchesDir();
4042
- const tmpDir = path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
4043
- try {
4044
- this.host.moveDir(patchesCacheDir, tmpDir);
4045
- this.host.deleteDir(tmpDir);
4046
- return {};
4047
- } catch (err) {
4048
- if (err instanceof Error) {
5140
+ const patchesDir = this.getPatchesDir();
5141
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5142
+ ttlMs: 30_000,
5143
+ op: "delete all patches"
5144
+ }, () => {
5145
+ if (!fs.existsSync(patchesDir)) {
5146
+ return {};
5147
+ }
5148
+ const tmpDir = path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
5149
+ try {
5150
+ // One rename takes the log and the directories together, so the store
5151
+ // is empty and self-consistent from the first instant rather than
5152
+ // part-way through. It is also the cleanup route for a store this
5153
+ // version refuses to read: discarding everything always works.
5154
+ this.host.moveDir(patchesDir, tmpDir);
5155
+ this.host.deleteDir(tmpDir);
5156
+ return {};
5157
+ } catch (err) {
4049
5158
  return {
4050
5159
  error: {
4051
- message: `Got an error while deleting patches: ${err.message}`
5160
+ message: `Got an error while deleting patches: ${err instanceof Error ? err.message : "unknown error"}`
4052
5161
  }
4053
5162
  };
4054
5163
  }
5164
+ });
5165
+ if (locked.status !== "ok") {
4055
5166
  return {
4056
5167
  error: {
4057
- message: "Got an unexpected error while deleting patches"
5168
+ message: locked.message
4058
5169
  }
4059
5170
  };
4060
5171
  }
4061
- }
4062
- updateOrderedPatches(updates, patchDirMap, deletePatchIds) {
4063
- for (const patchId of deletePatchIds) {
4064
- const patchDir = patchDirMap[patchId];
4065
- if (!patchDir) {
4066
- console.error("Could not find patch dir for patch id scheduled for deletion: ", patchId);
4067
- continue;
4068
- }
4069
- try {
4070
- this.host.deleteDir(this.getFullPatchDir(patchDir));
4071
- } catch (err) {
4072
- console.error("Failed to delete patch dir", err);
4073
- }
4074
- }
4075
- for (const [patchIdS, parentRef] of Object.entries(updates.changedPatches)) {
4076
- const prevParentPatchId = patchDirMap[patchIdS];
4077
- if (!prevParentPatchId) {
4078
- console.error("Could not find previous parent patch id for deleted patch id: ", patchIdS);
4079
- continue;
4080
- }
4081
- const newParentPatchId = parentRef.type === "head" ? "head" : parentRef.patchId;
4082
- const currentPatchDataRes = this.parseJsonFile(this.getPatchFilePath(prevParentPatchId), FSPatch);
4083
- if (currentPatchDataRes.error) {
4084
- console.error("Failed to parse patch file while fixing patch chain after deleted patch", {
4085
- updates
4086
- }, currentPatchDataRes.error);
4087
- continue;
4088
- }
4089
- const newPatchData = currentPatchDataRes.data;
4090
- newPatchData.parentRef = parentRef;
4091
- try {
4092
- this.host.writeUf8File(this.getPatchFilePath(prevParentPatchId), JSON.stringify(newPatchData));
4093
- if (this.host.directoryExists(this.getFullPatchDir(newParentPatchId))) {
4094
- this.host.deleteDir(this.getFullPatchDir(newParentPatchId));
4095
- }
4096
- this.host.moveDir(this.getFullPatchDir(prevParentPatchId), this.getFullPatchDir(newParentPatchId));
4097
- } catch (err) {
4098
- console.error("Failed fix patch chain after deleted patch", {
4099
- updates
4100
- }, err);
4101
- }
4102
- }
5172
+ return locked.value;
4103
5173
  }
4104
5174
  async saveOrUploadFiles(preparedCommit, mode, auth) {
4105
5175
  const updatedFiles = [];
@@ -4162,30 +5232,13 @@ class ValOpsFS extends ValOps {
4162
5232
  uploadedRemoteRefs.push(ref);
4163
5233
  }
4164
5234
  }
4165
- const patchIdToPatchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4166
- if (result.isErr(patchIdToPatchDirMapRes)) {
4167
- return {
4168
- updatedFiles,
4169
- uploadedRemoteRefs,
4170
- errors
4171
- };
4172
- }
4173
- const patchIdToPatchDirMap = patchIdToPatchDirMapRes.value;
4174
5235
  for (const [ref, {
4175
5236
  patchId
4176
5237
  }] of localFileDescriptors) {
4177
5238
  const filePath = ref;
4178
5239
  const absPath = path__default.join(this.rootDir, ...filePath.split("/"));
4179
5240
  try {
4180
- const patchDir = patchIdToPatchDirMap[patchId];
4181
- if (!patchDir) {
4182
- errors[absPath] = {
4183
- message: "Failed to find PatchDir for PatchId " + patchId,
4184
- filePath: filePath
4185
- };
4186
- continue;
4187
- }
4188
- this.host.copyFile(this.getBinaryFilePath(filePath, patchDir), absPath);
5241
+ this.host.copyFile(patchBinaryFile(this.getPatchesDir(), patchId, filePath), absPath);
4189
5242
  updatedFiles.push(absPath);
4190
5243
  } catch (err) {
4191
5244
  errors[absPath] = {
@@ -4215,14 +5268,7 @@ class ValOpsFS extends ValOps {
4215
5268
  baseSha: await this.getBaseSha(),
4216
5269
  timestamp: new Date().toISOString()
4217
5270
  };
4218
- const patchDir = patchIdToPatchDirMap[patchId];
4219
- if (!patchDir) {
4220
- errors[`patchId:${patchId}`] = {
4221
- message: "Failed to find PatchDir for PatchId " + patchId
4222
- };
4223
- continue;
4224
- }
4225
- const absPath = this.getPatchBaseFile(patchDir);
5271
+ const absPath = patchBaseFile(this.getPatchesDir(), patchId);
4226
5272
  try {
4227
5273
  this.host.writeUf8File(absPath, JSON.stringify(appliedAt));
4228
5274
  } catch (err) {
@@ -4267,47 +5313,18 @@ class ValOpsFS extends ValOps {
4267
5313
  }
4268
5314
  return createMetadataFromBuffer(type, mimeType, buffer);
4269
5315
  }
4270
- async getParentPatchIdFromPatchId(patchId) {
4271
- // This is not great. If needed we should find a better way
4272
- const patches = await this.readPatches();
4273
- if (patches.errors || patches.error) {
4274
- console.error("Failed to read patches", JSON.stringify(patches));
4275
- return result.err("failed-to-read-patches");
4276
- }
4277
- const patch = patches.patches[patchId];
4278
- if (!patch) {
4279
- console.error("Could not find patch with patchId: ", patchId);
4280
- return result.err("patch-not-found");
4281
- }
4282
- return result.ok(this.getParentPatchIdFromParentRef(patch.parentRef));
4283
- }
4284
- async getParentPatchIdFromPatchIdMap() {
4285
- const patches = await this.readPatches();
4286
- if (patches.errors || patches.error) {
4287
- console.error("Failed to read patches", JSON.stringify(patches));
4288
- return result.err("failed-to-read-patches");
4289
- }
4290
- return result.ok(Object.fromEntries(Object.entries(patches.patches).map(([patchId, value]) => [patchId, this.getParentPatchIdFromParentRef(value.parentRef)])));
4291
- }
4292
5316
 
4293
5317
  // #region fs file path helpers
4294
5318
  getPatchesDir() {
4295
5319
  return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches");
4296
5320
  }
4297
- getFullPatchDir(patchDir) {
4298
- return path__default.join(this.getPatchesDir(), patchDir);
4299
- }
4300
- getBinaryFilePath(filePath, patchDir) {
4301
- return path__default.join(this.getFullPatchDir(patchDir), "files", filePath, path__default.basename(filePath));
4302
- }
4303
- getBinaryFileMetadataPath(filePath, patchDir) {
4304
- return path__default.join(this.getFullPatchDir(patchDir), "files", filePath, "metadata.json");
4305
- }
4306
- getPatchFilePath(patchDir) {
4307
- return path__default.join(this.getFullPatchDir(patchDir), "patch.json");
4308
- }
4309
- getPatchBaseFile(patchDir) {
4310
- return path__default.join(this.getFullPatchDir(patchDir), "base.json");
5321
+
5322
+ /**
5323
+ * Deliberately outside the patches directory: delete-all and reset rename that
5324
+ * whole directory, and a lock that moves away with it is not holding anything.
5325
+ */
5326
+ getPatchLockFile() {
5327
+ return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
4311
5328
  }
4312
5329
  }
4313
5330
  class FSOpsHost {
@@ -4390,22 +5407,6 @@ class FSOpsHost {
4390
5407
  fs.copyFileSync(from, to);
4391
5408
  }
4392
5409
  }
4393
- const FSPatch = z.object({
4394
- path: z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
4395
- patch: Patch,
4396
- patchId: z.string(),
4397
- baseSha: z.string(),
4398
- parentRef: ParentRef,
4399
- authorId: z.string().refine(p => true).nullable(),
4400
- createdAt: z.string().datetime(),
4401
- coreVersion: z.string().nullable(),
4402
- // TODO: use this to check if patch is compatible with current core version?
4403
- sessionId: z.string().nullable()
4404
- });
4405
- const FSPatchBase = z.object({
4406
- baseSha: z.string().refine(p => true),
4407
- timestamp: z.string().datetime()
4408
- });
4409
5410
 
4410
5411
  const textEncoder$2 = new TextEncoder();
4411
5412
  const PatchId = z.string().refine(s => !!s); // TODO: validate
@@ -6431,7 +7432,7 @@ const ValServer = (valModules, options, callbacks) => {
6431
7432
  status: 409,
6432
7433
  json: {
6433
7434
  type: "patch-head-conflict",
6434
- message: "Patch id conflict"
7435
+ message: "The change was written against a different starting point than the server has."
6435
7436
  }
6436
7437
  };
6437
7438
  } else {
@@ -10759,4 +11760,4 @@ function readCapturedReport(snapshotDir) {
10759
11760
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
10760
11761
  }
10761
11762
 
10762
- export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, currentFixHandlers, decodeJwt, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
11763
+ export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, currentFixHandlers, decodeJwt, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };