@valbuild/server 0.103.1 → 0.103.2

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.
@@ -5442,6 +5442,112 @@ class FSOpsHost {
5442
5442
  }
5443
5443
  }
5444
5444
 
5445
+ /**
5446
+ * Which unpublished changes a failed save has to throw away to make progress.
5447
+ *
5448
+ * `/save` used to refuse the whole commit when any one patch could not be
5449
+ * applied: nothing was written, for any module, and the change stayed on disk to
5450
+ * fail again on the next attempt. With auto-save that is not a refusal, it is a
5451
+ * dead stop — the editor keeps typing, every save fails on the same patch, and
5452
+ * nothing is ever written again.
5453
+ *
5454
+ * So in `fs` mode the failing change is removed instead, and the person editing
5455
+ * is told. The rule for what goes with it:
5456
+ *
5457
+ * **From the first failure onward, within that module.** A patch is a set of ops
5458
+ * against the source as the patches before it left it. Once one has not been
5459
+ * applied, every later patch in that module was computed against a state that no
5460
+ * longer exists, so keeping them would be keeping changes whose meaning has
5461
+ * already been lost. That is `triedPatches` (the one that failed) plus
5462
+ * `skippedPatches` (the tail that was never attempted).
5463
+ *
5464
+ * **Other modules are untouched.** `prepare` walks each module's chain
5465
+ * independently, so a broken chain in one file says nothing about another.
5466
+ *
5467
+ * **And a module that failed as a WHOLE keeps everything.** When the `.val.ts`
5468
+ * cannot be read at all, `prepare` reports every one of its patches as skipped
5469
+ * and names none of them as unappliable — the file is what failed, not the
5470
+ * changes. Deleting them there would be destroying good edits because a file was
5471
+ * missing for a moment: rename a module, or switch branch, with unsaved changes
5472
+ * to it and the next auto-save would silently take them all. Skipped is only
5473
+ * collateral when there is something in that module for it to be collateral OF.
5474
+ */
5475
+
5476
+ /**
5477
+ * The message for a patch that did not itself fail but sits behind one that did.
5478
+ *
5479
+ * Worth distinguishing: "yours was broken" and "something before yours was
5480
+ * broken" are different things to be told, and only the first is a bug in the
5481
+ * change itself.
5482
+ */
5483
+ function collateralMessage(moduleFilePath) {
5484
+ return `An earlier change to ${moduleFilePath} could not be applied, so this one no longer fits the content it was written against.`;
5485
+ }
5486
+ function computePatchesToDrop(prepared) {
5487
+ const dropped = [];
5488
+ const seen = new Set();
5489
+ const add = (moduleFilePath, patchId) => {
5490
+ var _prepared$unappliable;
5491
+ if (seen.has(patchId)) {
5492
+ return;
5493
+ }
5494
+ seen.add(patchId);
5495
+ dropped.push({
5496
+ patchId,
5497
+ moduleFilePath,
5498
+ message: ((_prepared$unappliable = prepared.unappliablePatches[patchId]) === null || _prepared$unappliable === void 0 ? void 0 : _prepared$unappliable.message) ?? collateralMessage(moduleFilePath)
5499
+ });
5500
+ };
5501
+
5502
+ // Tried before skipped, so the change that actually failed is named first —
5503
+ // it is the one worth reading.
5504
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5505
+ for (const patchId of patchIds) {
5506
+ add(moduleFilePath, patchId);
5507
+ }
5508
+ }
5509
+ /*
5510
+ * Which modules actually had a change fail, as opposed to being unreadable.
5511
+ *
5512
+ * `triedPatches` holds only failures, and `unappliablePatches` names the
5513
+ * failure itself, so between them they are the whole answer. A module in
5514
+ * neither has nothing to blame its skipped tail on.
5515
+ */
5516
+ const failedModules = new Set();
5517
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5518
+ if (patchIds.length > 0) {
5519
+ failedModules.add(moduleFilePath);
5520
+ }
5521
+ }
5522
+ for (const failure of Object.values(prepared.unappliablePatches)) {
5523
+ failedModules.add(failure.moduleFilePath);
5524
+ }
5525
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.skippedPatches)) {
5526
+ if (!failedModules.has(moduleFilePath)) {
5527
+ // The module failed as a whole. Its changes are fine and are kept, so the
5528
+ // save refuses instead — which is what it did before auto-save, and is
5529
+ // right here: this one resolves itself when the file comes back.
5530
+ continue;
5531
+ }
5532
+ for (const patchId of patchIds) {
5533
+ add(moduleFilePath, patchId);
5534
+ }
5535
+ }
5536
+
5537
+ /*
5538
+ * A patch reported unappliable that neither list holds.
5539
+ *
5540
+ * Belt and braces: the two lists are how `prepare` reports the shape of the
5541
+ * failure, and this is how it reports the failure itself. If they ever
5542
+ * disagree, leaving a patch behind means the next save fails on it again —
5543
+ * the exact dead stop this function exists to prevent.
5544
+ */
5545
+ for (const [patchId, failure] of Object.entries(prepared.unappliablePatches)) {
5546
+ add(failure.moduleFilePath, patchId);
5547
+ }
5548
+ return dropped;
5549
+ }
5550
+
5445
5551
  const textEncoder$2 = new TextEncoder();
5446
5552
  const PatchId = z.z.string().refine(s => !!s); // TODO: validate
5447
5553
  const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
@@ -8139,11 +8245,65 @@ const ValServer = (valModules, options, callbacks) => {
8139
8245
  patchIds,
8140
8246
  excludePatchOps: false
8141
8247
  });
8248
+ /*
8249
+ * Exactly the patches this request consumes, and the ONLY ones it may
8250
+ * delete afterwards.
8251
+ *
8252
+ * Taken from what was fetched rather than from what was asked for, and
8253
+ * held across everything below, because the store keeps accepting writes
8254
+ * while this runs. A change typed during a save is not in this list, is
8255
+ * not applied, and must still be here when the save finishes — that is
8256
+ * the whole contract, and `deleteAllPatches` broke it by removing the
8257
+ * store wholesale.
8258
+ */
8259
+ const consumed = patches.patches.map(patch => patch.patchId);
8142
8260
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
8143
- const preparedCommit = await serverOps.prepare({
8261
+ let preparedCommit = await serverOps.prepare({
8144
8262
  ...analysis,
8145
8263
  ...patches
8146
8264
  });
8265
+ /*
8266
+ * In `fs` mode a change that cannot be applied is removed rather than
8267
+ * blocking the save.
8268
+ *
8269
+ * Refusing the whole commit is right for a git commit — it is one
8270
+ * atomic thing, and the content api owns its own patches. It is wrong
8271
+ * for auto-save: the editor keeps typing, every save fails on the same
8272
+ * patch, and nothing is ever written again. So the failing change and
8273
+ * the rest of its module's chain go, the rest is written, and the person
8274
+ * editing is told what was thrown away.
8275
+ */
8276
+ let removed = [];
8277
+ if (preparedCommit.hasErrors && serverOps instanceof ValOpsFS) {
8278
+ removed = computePatchesToDrop(preparedCommit);
8279
+ /*
8280
+ * Nothing to drop means nothing a second `prepare` could do better.
8281
+ *
8282
+ * `hasErrors` covers failures no patch is to blame for — a binary file
8283
+ * that could not be written, a module that could not be formatted, a
8284
+ * `.val.ts` that could not be read at all. Re-preparing every module
8285
+ * with an unchanged patch set would fail identically, and logging
8286
+ * "removed unpublished changes []" on the way would say something
8287
+ * untrue. It falls through to the 400 instead, which is the honest
8288
+ * answer: this save did not happen and the changes are still here.
8289
+ */
8290
+ if (removed.length > 0) {
8291
+ const doomed = new Set(removed.map(entry => entry.patchId));
8292
+ const survivors = patches.patches.filter(patch => !doomed.has(patch.patchId));
8293
+ const survivingPatches = {
8294
+ ...patches,
8295
+ patches: survivors
8296
+ };
8297
+ // One retry is enough: what is left of each module is the prefix
8298
+ // that already applied, and `prepare` walks modules independently,
8299
+ // so nothing new can fail.
8300
+ preparedCommit = await serverOps.prepare({
8301
+ ...serverOps.analyzePatches(survivors, patches.commits, commit),
8302
+ ...survivingPatches
8303
+ });
8304
+ console.error("Val: removed unpublished changes that could not be applied", removed);
8305
+ }
8306
+ }
8147
8307
  if (preparedCommit.hasErrors) {
8148
8308
  console.error("Failed to create commit", JSON.stringify({
8149
8309
  sourceFilePatchErrors: preparedCommit.sourceFilePatchErrors,
@@ -8196,13 +8356,31 @@ const ValServer = (valModules, options, callbacks) => {
8196
8356
  }
8197
8357
  };
8198
8358
  }
8199
- const deleteRes = await serverOps.deleteAllPatches();
8200
- if (deleteRes.error) {
8359
+ /*
8360
+ * Only what this request consumed.
8361
+ *
8362
+ * This used to be `deleteAllPatches()`, which renamed the whole store
8363
+ * aside and deleted it. Anything typed while the save was in flight
8364
+ * went with it — never applied, and indistinguishable on the client
8365
+ * from having been published, because both look like "the patch is
8366
+ * gone and the base moved". A visible edit that exists nowhere on
8367
+ * disk, with nothing reporting a problem.
8368
+ */
8369
+ const deleteRes = await serverOps.deletePatches(consumed);
8370
+ if ("error" in deleteRes && deleteRes.error !== undefined) {
8201
8371
  console.error(`Val got an error while cleaning up patches after publish: ${deleteRes.error.message}`);
8372
+ } else if ("errors" in deleteRes && deleteRes.errors !== undefined) {
8373
+ // Per patch, and not fatal: the files are written, so the save
8374
+ // succeeded. A patch left behind is announced again on the next
8375
+ // stat, applies to a base that already contains it, and is removed
8376
+ // then.
8377
+ console.error("Val: could not clean up some patches after publish", deleteRes.errors);
8202
8378
  }
8203
8379
  return {
8204
8380
  status: 200,
8205
- json: {} // TODO:
8381
+ json: removed.length > 0 ? {
8382
+ removed
8383
+ } : {}
8206
8384
  };
8207
8385
  } else if (serverOps instanceof ValOpsHttp) {
8208
8386
  if (auth.error === undefined && auth.id) {
@@ -5442,6 +5442,112 @@ class FSOpsHost {
5442
5442
  }
5443
5443
  }
5444
5444
 
5445
+ /**
5446
+ * Which unpublished changes a failed save has to throw away to make progress.
5447
+ *
5448
+ * `/save` used to refuse the whole commit when any one patch could not be
5449
+ * applied: nothing was written, for any module, and the change stayed on disk to
5450
+ * fail again on the next attempt. With auto-save that is not a refusal, it is a
5451
+ * dead stop — the editor keeps typing, every save fails on the same patch, and
5452
+ * nothing is ever written again.
5453
+ *
5454
+ * So in `fs` mode the failing change is removed instead, and the person editing
5455
+ * is told. The rule for what goes with it:
5456
+ *
5457
+ * **From the first failure onward, within that module.** A patch is a set of ops
5458
+ * against the source as the patches before it left it. Once one has not been
5459
+ * applied, every later patch in that module was computed against a state that no
5460
+ * longer exists, so keeping them would be keeping changes whose meaning has
5461
+ * already been lost. That is `triedPatches` (the one that failed) plus
5462
+ * `skippedPatches` (the tail that was never attempted).
5463
+ *
5464
+ * **Other modules are untouched.** `prepare` walks each module's chain
5465
+ * independently, so a broken chain in one file says nothing about another.
5466
+ *
5467
+ * **And a module that failed as a WHOLE keeps everything.** When the `.val.ts`
5468
+ * cannot be read at all, `prepare` reports every one of its patches as skipped
5469
+ * and names none of them as unappliable — the file is what failed, not the
5470
+ * changes. Deleting them there would be destroying good edits because a file was
5471
+ * missing for a moment: rename a module, or switch branch, with unsaved changes
5472
+ * to it and the next auto-save would silently take them all. Skipped is only
5473
+ * collateral when there is something in that module for it to be collateral OF.
5474
+ */
5475
+
5476
+ /**
5477
+ * The message for a patch that did not itself fail but sits behind one that did.
5478
+ *
5479
+ * Worth distinguishing: "yours was broken" and "something before yours was
5480
+ * broken" are different things to be told, and only the first is a bug in the
5481
+ * change itself.
5482
+ */
5483
+ function collateralMessage(moduleFilePath) {
5484
+ return `An earlier change to ${moduleFilePath} could not be applied, so this one no longer fits the content it was written against.`;
5485
+ }
5486
+ function computePatchesToDrop(prepared) {
5487
+ const dropped = [];
5488
+ const seen = new Set();
5489
+ const add = (moduleFilePath, patchId) => {
5490
+ var _prepared$unappliable;
5491
+ if (seen.has(patchId)) {
5492
+ return;
5493
+ }
5494
+ seen.add(patchId);
5495
+ dropped.push({
5496
+ patchId,
5497
+ moduleFilePath,
5498
+ message: ((_prepared$unappliable = prepared.unappliablePatches[patchId]) === null || _prepared$unappliable === void 0 ? void 0 : _prepared$unappliable.message) ?? collateralMessage(moduleFilePath)
5499
+ });
5500
+ };
5501
+
5502
+ // Tried before skipped, so the change that actually failed is named first —
5503
+ // it is the one worth reading.
5504
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5505
+ for (const patchId of patchIds) {
5506
+ add(moduleFilePath, patchId);
5507
+ }
5508
+ }
5509
+ /*
5510
+ * Which modules actually had a change fail, as opposed to being unreadable.
5511
+ *
5512
+ * `triedPatches` holds only failures, and `unappliablePatches` names the
5513
+ * failure itself, so between them they are the whole answer. A module in
5514
+ * neither has nothing to blame its skipped tail on.
5515
+ */
5516
+ const failedModules = new Set();
5517
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5518
+ if (patchIds.length > 0) {
5519
+ failedModules.add(moduleFilePath);
5520
+ }
5521
+ }
5522
+ for (const failure of Object.values(prepared.unappliablePatches)) {
5523
+ failedModules.add(failure.moduleFilePath);
5524
+ }
5525
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.skippedPatches)) {
5526
+ if (!failedModules.has(moduleFilePath)) {
5527
+ // The module failed as a whole. Its changes are fine and are kept, so the
5528
+ // save refuses instead — which is what it did before auto-save, and is
5529
+ // right here: this one resolves itself when the file comes back.
5530
+ continue;
5531
+ }
5532
+ for (const patchId of patchIds) {
5533
+ add(moduleFilePath, patchId);
5534
+ }
5535
+ }
5536
+
5537
+ /*
5538
+ * A patch reported unappliable that neither list holds.
5539
+ *
5540
+ * Belt and braces: the two lists are how `prepare` reports the shape of the
5541
+ * failure, and this is how it reports the failure itself. If they ever
5542
+ * disagree, leaving a patch behind means the next save fails on it again —
5543
+ * the exact dead stop this function exists to prevent.
5544
+ */
5545
+ for (const [patchId, failure] of Object.entries(prepared.unappliablePatches)) {
5546
+ add(failure.moduleFilePath, patchId);
5547
+ }
5548
+ return dropped;
5549
+ }
5550
+
5445
5551
  const textEncoder$2 = new TextEncoder();
5446
5552
  const PatchId = z.z.string().refine(s => !!s); // TODO: validate
5447
5553
  const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
@@ -8139,11 +8245,65 @@ const ValServer = (valModules, options, callbacks) => {
8139
8245
  patchIds,
8140
8246
  excludePatchOps: false
8141
8247
  });
8248
+ /*
8249
+ * Exactly the patches this request consumes, and the ONLY ones it may
8250
+ * delete afterwards.
8251
+ *
8252
+ * Taken from what was fetched rather than from what was asked for, and
8253
+ * held across everything below, because the store keeps accepting writes
8254
+ * while this runs. A change typed during a save is not in this list, is
8255
+ * not applied, and must still be here when the save finishes — that is
8256
+ * the whole contract, and `deleteAllPatches` broke it by removing the
8257
+ * store wholesale.
8258
+ */
8259
+ const consumed = patches.patches.map(patch => patch.patchId);
8142
8260
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
8143
- const preparedCommit = await serverOps.prepare({
8261
+ let preparedCommit = await serverOps.prepare({
8144
8262
  ...analysis,
8145
8263
  ...patches
8146
8264
  });
8265
+ /*
8266
+ * In `fs` mode a change that cannot be applied is removed rather than
8267
+ * blocking the save.
8268
+ *
8269
+ * Refusing the whole commit is right for a git commit — it is one
8270
+ * atomic thing, and the content api owns its own patches. It is wrong
8271
+ * for auto-save: the editor keeps typing, every save fails on the same
8272
+ * patch, and nothing is ever written again. So the failing change and
8273
+ * the rest of its module's chain go, the rest is written, and the person
8274
+ * editing is told what was thrown away.
8275
+ */
8276
+ let removed = [];
8277
+ if (preparedCommit.hasErrors && serverOps instanceof ValOpsFS) {
8278
+ removed = computePatchesToDrop(preparedCommit);
8279
+ /*
8280
+ * Nothing to drop means nothing a second `prepare` could do better.
8281
+ *
8282
+ * `hasErrors` covers failures no patch is to blame for — a binary file
8283
+ * that could not be written, a module that could not be formatted, a
8284
+ * `.val.ts` that could not be read at all. Re-preparing every module
8285
+ * with an unchanged patch set would fail identically, and logging
8286
+ * "removed unpublished changes []" on the way would say something
8287
+ * untrue. It falls through to the 400 instead, which is the honest
8288
+ * answer: this save did not happen and the changes are still here.
8289
+ */
8290
+ if (removed.length > 0) {
8291
+ const doomed = new Set(removed.map(entry => entry.patchId));
8292
+ const survivors = patches.patches.filter(patch => !doomed.has(patch.patchId));
8293
+ const survivingPatches = {
8294
+ ...patches,
8295
+ patches: survivors
8296
+ };
8297
+ // One retry is enough: what is left of each module is the prefix
8298
+ // that already applied, and `prepare` walks modules independently,
8299
+ // so nothing new can fail.
8300
+ preparedCommit = await serverOps.prepare({
8301
+ ...serverOps.analyzePatches(survivors, patches.commits, commit),
8302
+ ...survivingPatches
8303
+ });
8304
+ console.error("Val: removed unpublished changes that could not be applied", removed);
8305
+ }
8306
+ }
8147
8307
  if (preparedCommit.hasErrors) {
8148
8308
  console.error("Failed to create commit", JSON.stringify({
8149
8309
  sourceFilePatchErrors: preparedCommit.sourceFilePatchErrors,
@@ -8196,13 +8356,31 @@ const ValServer = (valModules, options, callbacks) => {
8196
8356
  }
8197
8357
  };
8198
8358
  }
8199
- const deleteRes = await serverOps.deleteAllPatches();
8200
- if (deleteRes.error) {
8359
+ /*
8360
+ * Only what this request consumed.
8361
+ *
8362
+ * This used to be `deleteAllPatches()`, which renamed the whole store
8363
+ * aside and deleted it. Anything typed while the save was in flight
8364
+ * went with it — never applied, and indistinguishable on the client
8365
+ * from having been published, because both look like "the patch is
8366
+ * gone and the base moved". A visible edit that exists nowhere on
8367
+ * disk, with nothing reporting a problem.
8368
+ */
8369
+ const deleteRes = await serverOps.deletePatches(consumed);
8370
+ if ("error" in deleteRes && deleteRes.error !== undefined) {
8201
8371
  console.error(`Val got an error while cleaning up patches after publish: ${deleteRes.error.message}`);
8372
+ } else if ("errors" in deleteRes && deleteRes.errors !== undefined) {
8373
+ // Per patch, and not fatal: the files are written, so the save
8374
+ // succeeded. A patch left behind is announced again on the next
8375
+ // stat, applies to a base that already contains it, and is removed
8376
+ // then.
8377
+ console.error("Val: could not clean up some patches after publish", deleteRes.errors);
8202
8378
  }
8203
8379
  return {
8204
8380
  status: 200,
8205
- json: {} // TODO:
8381
+ json: removed.length > 0 ? {
8382
+ removed
8383
+ } : {}
8206
8384
  };
8207
8385
  } else if (serverOps instanceof ValOpsHttp) {
8208
8386
  if (auth.error === undefined && auth.id) {
@@ -5408,6 +5408,112 @@ class FSOpsHost {
5408
5408
  }
5409
5409
  }
5410
5410
 
5411
+ /**
5412
+ * Which unpublished changes a failed save has to throw away to make progress.
5413
+ *
5414
+ * `/save` used to refuse the whole commit when any one patch could not be
5415
+ * applied: nothing was written, for any module, and the change stayed on disk to
5416
+ * fail again on the next attempt. With auto-save that is not a refusal, it is a
5417
+ * dead stop — the editor keeps typing, every save fails on the same patch, and
5418
+ * nothing is ever written again.
5419
+ *
5420
+ * So in `fs` mode the failing change is removed instead, and the person editing
5421
+ * is told. The rule for what goes with it:
5422
+ *
5423
+ * **From the first failure onward, within that module.** A patch is a set of ops
5424
+ * against the source as the patches before it left it. Once one has not been
5425
+ * applied, every later patch in that module was computed against a state that no
5426
+ * longer exists, so keeping them would be keeping changes whose meaning has
5427
+ * already been lost. That is `triedPatches` (the one that failed) plus
5428
+ * `skippedPatches` (the tail that was never attempted).
5429
+ *
5430
+ * **Other modules are untouched.** `prepare` walks each module's chain
5431
+ * independently, so a broken chain in one file says nothing about another.
5432
+ *
5433
+ * **And a module that failed as a WHOLE keeps everything.** When the `.val.ts`
5434
+ * cannot be read at all, `prepare` reports every one of its patches as skipped
5435
+ * and names none of them as unappliable — the file is what failed, not the
5436
+ * changes. Deleting them there would be destroying good edits because a file was
5437
+ * missing for a moment: rename a module, or switch branch, with unsaved changes
5438
+ * to it and the next auto-save would silently take them all. Skipped is only
5439
+ * collateral when there is something in that module for it to be collateral OF.
5440
+ */
5441
+
5442
+ /**
5443
+ * The message for a patch that did not itself fail but sits behind one that did.
5444
+ *
5445
+ * Worth distinguishing: "yours was broken" and "something before yours was
5446
+ * broken" are different things to be told, and only the first is a bug in the
5447
+ * change itself.
5448
+ */
5449
+ function collateralMessage(moduleFilePath) {
5450
+ return `An earlier change to ${moduleFilePath} could not be applied, so this one no longer fits the content it was written against.`;
5451
+ }
5452
+ function computePatchesToDrop(prepared) {
5453
+ const dropped = [];
5454
+ const seen = new Set();
5455
+ const add = (moduleFilePath, patchId) => {
5456
+ var _prepared$unappliable;
5457
+ if (seen.has(patchId)) {
5458
+ return;
5459
+ }
5460
+ seen.add(patchId);
5461
+ dropped.push({
5462
+ patchId,
5463
+ moduleFilePath,
5464
+ message: ((_prepared$unappliable = prepared.unappliablePatches[patchId]) === null || _prepared$unappliable === void 0 ? void 0 : _prepared$unappliable.message) ?? collateralMessage(moduleFilePath)
5465
+ });
5466
+ };
5467
+
5468
+ // Tried before skipped, so the change that actually failed is named first —
5469
+ // it is the one worth reading.
5470
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5471
+ for (const patchId of patchIds) {
5472
+ add(moduleFilePath, patchId);
5473
+ }
5474
+ }
5475
+ /*
5476
+ * Which modules actually had a change fail, as opposed to being unreadable.
5477
+ *
5478
+ * `triedPatches` holds only failures, and `unappliablePatches` names the
5479
+ * failure itself, so between them they are the whole answer. A module in
5480
+ * neither has nothing to blame its skipped tail on.
5481
+ */
5482
+ const failedModules = new Set();
5483
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.triedPatches)) {
5484
+ if (patchIds.length > 0) {
5485
+ failedModules.add(moduleFilePath);
5486
+ }
5487
+ }
5488
+ for (const failure of Object.values(prepared.unappliablePatches)) {
5489
+ failedModules.add(failure.moduleFilePath);
5490
+ }
5491
+ for (const [moduleFilePath, patchIds] of Object.entries(prepared.skippedPatches)) {
5492
+ if (!failedModules.has(moduleFilePath)) {
5493
+ // The module failed as a whole. Its changes are fine and are kept, so the
5494
+ // save refuses instead — which is what it did before auto-save, and is
5495
+ // right here: this one resolves itself when the file comes back.
5496
+ continue;
5497
+ }
5498
+ for (const patchId of patchIds) {
5499
+ add(moduleFilePath, patchId);
5500
+ }
5501
+ }
5502
+
5503
+ /*
5504
+ * A patch reported unappliable that neither list holds.
5505
+ *
5506
+ * Belt and braces: the two lists are how `prepare` reports the shape of the
5507
+ * failure, and this is how it reports the failure itself. If they ever
5508
+ * disagree, leaving a patch behind means the next save fails on it again —
5509
+ * the exact dead stop this function exists to prevent.
5510
+ */
5511
+ for (const [patchId, failure] of Object.entries(prepared.unappliablePatches)) {
5512
+ add(failure.moduleFilePath, patchId);
5513
+ }
5514
+ return dropped;
5515
+ }
5516
+
5411
5517
  const textEncoder$2 = new TextEncoder();
5412
5518
  const PatchId = z.string().refine(s => !!s); // TODO: validate
5413
5519
  const CommitSha = z.string().refine(s => !!s); // TODO: validate
@@ -8105,11 +8211,65 @@ const ValServer = (valModules, options, callbacks) => {
8105
8211
  patchIds,
8106
8212
  excludePatchOps: false
8107
8213
  });
8214
+ /*
8215
+ * Exactly the patches this request consumes, and the ONLY ones it may
8216
+ * delete afterwards.
8217
+ *
8218
+ * Taken from what was fetched rather than from what was asked for, and
8219
+ * held across everything below, because the store keeps accepting writes
8220
+ * while this runs. A change typed during a save is not in this list, is
8221
+ * not applied, and must still be here when the save finishes — that is
8222
+ * the whole contract, and `deleteAllPatches` broke it by removing the
8223
+ * store wholesale.
8224
+ */
8225
+ const consumed = patches.patches.map(patch => patch.patchId);
8108
8226
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
8109
- const preparedCommit = await serverOps.prepare({
8227
+ let preparedCommit = await serverOps.prepare({
8110
8228
  ...analysis,
8111
8229
  ...patches
8112
8230
  });
8231
+ /*
8232
+ * In `fs` mode a change that cannot be applied is removed rather than
8233
+ * blocking the save.
8234
+ *
8235
+ * Refusing the whole commit is right for a git commit — it is one
8236
+ * atomic thing, and the content api owns its own patches. It is wrong
8237
+ * for auto-save: the editor keeps typing, every save fails on the same
8238
+ * patch, and nothing is ever written again. So the failing change and
8239
+ * the rest of its module's chain go, the rest is written, and the person
8240
+ * editing is told what was thrown away.
8241
+ */
8242
+ let removed = [];
8243
+ if (preparedCommit.hasErrors && serverOps instanceof ValOpsFS) {
8244
+ removed = computePatchesToDrop(preparedCommit);
8245
+ /*
8246
+ * Nothing to drop means nothing a second `prepare` could do better.
8247
+ *
8248
+ * `hasErrors` covers failures no patch is to blame for — a binary file
8249
+ * that could not be written, a module that could not be formatted, a
8250
+ * `.val.ts` that could not be read at all. Re-preparing every module
8251
+ * with an unchanged patch set would fail identically, and logging
8252
+ * "removed unpublished changes []" on the way would say something
8253
+ * untrue. It falls through to the 400 instead, which is the honest
8254
+ * answer: this save did not happen and the changes are still here.
8255
+ */
8256
+ if (removed.length > 0) {
8257
+ const doomed = new Set(removed.map(entry => entry.patchId));
8258
+ const survivors = patches.patches.filter(patch => !doomed.has(patch.patchId));
8259
+ const survivingPatches = {
8260
+ ...patches,
8261
+ patches: survivors
8262
+ };
8263
+ // One retry is enough: what is left of each module is the prefix
8264
+ // that already applied, and `prepare` walks modules independently,
8265
+ // so nothing new can fail.
8266
+ preparedCommit = await serverOps.prepare({
8267
+ ...serverOps.analyzePatches(survivors, patches.commits, commit),
8268
+ ...survivingPatches
8269
+ });
8270
+ console.error("Val: removed unpublished changes that could not be applied", removed);
8271
+ }
8272
+ }
8113
8273
  if (preparedCommit.hasErrors) {
8114
8274
  console.error("Failed to create commit", JSON.stringify({
8115
8275
  sourceFilePatchErrors: preparedCommit.sourceFilePatchErrors,
@@ -8162,13 +8322,31 @@ const ValServer = (valModules, options, callbacks) => {
8162
8322
  }
8163
8323
  };
8164
8324
  }
8165
- const deleteRes = await serverOps.deleteAllPatches();
8166
- if (deleteRes.error) {
8325
+ /*
8326
+ * Only what this request consumed.
8327
+ *
8328
+ * This used to be `deleteAllPatches()`, which renamed the whole store
8329
+ * aside and deleted it. Anything typed while the save was in flight
8330
+ * went with it — never applied, and indistinguishable on the client
8331
+ * from having been published, because both look like "the patch is
8332
+ * gone and the base moved". A visible edit that exists nowhere on
8333
+ * disk, with nothing reporting a problem.
8334
+ */
8335
+ const deleteRes = await serverOps.deletePatches(consumed);
8336
+ if ("error" in deleteRes && deleteRes.error !== undefined) {
8167
8337
  console.error(`Val got an error while cleaning up patches after publish: ${deleteRes.error.message}`);
8338
+ } else if ("errors" in deleteRes && deleteRes.errors !== undefined) {
8339
+ // Per patch, and not fatal: the files are written, so the save
8340
+ // succeeded. A patch left behind is announced again on the next
8341
+ // stat, applies to a base that already contains it, and is removed
8342
+ // then.
8343
+ console.error("Val: could not clean up some patches after publish", deleteRes.errors);
8168
8344
  }
8169
8345
  return {
8170
8346
  status: 200,
8171
- json: {} // TODO:
8347
+ json: removed.length > 0 ? {
8348
+ removed
8349
+ } : {}
8172
8350
  };
8173
8351
  } else if (serverOps instanceof ValOpsHttp) {
8174
8352
  if (auth.error === undefined && auth.id) {
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.103.1",
19
+ "version": "0.103.2",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0"
@@ -31,7 +31,7 @@
31
31
  "zod-validation-error": "^5.0.0",
32
32
  "@valbuild/core": "0.103.0",
33
33
  "@valbuild/shared": "0.103.0",
34
- "@valbuild/ui": "0.103.1"
34
+ "@valbuild/ui": "0.103.2"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.17.0"