@ebay-dt/bonfire-node 0.2.0-alpha.0 → 0.2.0-alpha.11

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.
Files changed (52) hide show
  1. package/README.md +18 -5
  2. package/dist/deployment-provider.d.ts +2 -1
  3. package/dist/deployment-provider.d.ts.map +1 -1
  4. package/dist/deployment-provider.js +2 -1
  5. package/dist/deployment-provider.js.map +1 -1
  6. package/dist/duplicate.d.ts.map +1 -1
  7. package/dist/duplicate.js +103 -47
  8. package/dist/duplicate.js.map +1 -1
  9. package/dist/git-repository.d.ts +16 -1
  10. package/dist/git-repository.d.ts.map +1 -1
  11. package/dist/git-repository.js +127 -1
  12. package/dist/git-repository.js.map +1 -1
  13. package/dist/github-cli.d.ts.map +1 -1
  14. package/dist/github-cli.js +3 -1
  15. package/dist/github-cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/prototype-templates.d.ts +0 -1
  21. package/dist/internal/prototype-templates.d.ts.map +1 -1
  22. package/dist/internal/prototype-templates.js +0 -5
  23. package/dist/internal/prototype-templates.js.map +1 -1
  24. package/dist/lifecycle/deployment-worktree.d.ts +36 -0
  25. package/dist/lifecycle/deployment-worktree.d.ts.map +1 -0
  26. package/dist/lifecycle/deployment-worktree.js +503 -0
  27. package/dist/lifecycle/deployment-worktree.js.map +1 -0
  28. package/dist/lifecycle/local-prototype-lifecycle.d.ts +15 -1
  29. package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
  30. package/dist/lifecycle/local-prototype-lifecycle.js +738 -98
  31. package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
  32. package/dist/lifecycle/preparation-marker.d.ts +12 -0
  33. package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
  34. package/dist/lifecycle/preparation-marker.js +76 -0
  35. package/dist/lifecycle/preparation-marker.js.map +1 -0
  36. package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
  37. package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
  38. package/dist/lifecycle/prototype-path-policy.js +429 -0
  39. package/dist/lifecycle/prototype-path-policy.js.map +1 -0
  40. package/dist/lifecycle/repository-summary.d.ts +7 -1
  41. package/dist/lifecycle/repository-summary.d.ts.map +1 -1
  42. package/dist/lifecycle/repository-summary.js +20 -4
  43. package/dist/lifecycle/repository-summary.js.map +1 -1
  44. package/dist/lifecycle/repository-synchronization.d.ts +13 -8
  45. package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
  46. package/dist/lifecycle/repository-synchronization.js +680 -139
  47. package/dist/lifecycle/repository-synchronization.js.map +1 -1
  48. package/dist/lifecycle/runtime-context.d.ts +2 -0
  49. package/dist/lifecycle/runtime-context.d.ts.map +1 -1
  50. package/dist/lifecycle/runtime-context.js +5 -1
  51. package/dist/lifecycle/runtime-context.js.map +1 -1
  52. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  import { constants } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
2
3
  import { lstat, open, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { randomUUID } from "node:crypto";
5
+ import { promisify } from "node:util";
4
6
  import { join, relative, sep } from "node:path";
5
7
  import { definePrototypeRef, getPrototypeUrl, LifecycleInputError, lifecycleFailure, lifecycleSuccess, parseCreatePrototypeInput, parseDuplicatePrototypeInput, parseEditPrototypeInput, parseBonfireIdentity, parsePrototypeCommandInput, parsePrototypeMetadata, slugifyPrototypeTitle, PrototypeRefError, parseGitModeSetting, } from "@ebay-dt/bonfire-core";
6
- import { commitPath, discardPath, GitRepositoryError, pushBranch, } from "../git-repository.js";
8
+ import { commitPath, GitRepositoryError, pushBranch, pushCommitToBranch, remoteBranchCommit, } from "../git-repository.js";
7
9
  import { duplicatePrototype, PrototypeDuplicateError } from "../duplicate.js";
8
10
  import { PrototypeCatalog, } from "../prototype-catalog.js";
9
11
  import { PrototypeScaffoldError, scaffoldPrototype } from "../scaffold.js";
@@ -13,10 +15,19 @@ import { RepositoryBusyError, withRepositoryOperation, } from "./operation-gate.
13
15
  import { DeploymentProviderError, } from "../deployment-provider.js";
14
16
  import { assertIdentityBranchReady, synchronizeIdentityBranch, } from "./repository-synchronization.js";
15
17
  import { getRepositorySummary } from "./repository-summary.js";
18
+ import { DeploymentWorktreeError, publishDeploymentSource, reportDeploymentIssue, } from "./deployment-worktree.js";
19
+ import { commitPrototypePath, deletePrototypePath, discardPrototypePath, } from "./prototype-path-policy.js";
20
+ import { clearPreparationMarker, readPreparationMarker, writePreparationMarker, } from "./preparation-marker.js";
21
+ const execFile = promisify(execFileCallback);
16
22
  function repositoryRelativePath(runtime, ref) {
17
23
  const absolutePath = join(runtime.prototypeRoot, ref.login, ref.slug);
18
24
  return relative(runtime.repositoryRoot, absolutePath).split(sep).join("/");
19
25
  }
26
+ function namespaceRelativePath(runtime, login) {
27
+ return relative(runtime.repositoryRoot, join(runtime.prototypeRoot, login))
28
+ .split(sep)
29
+ .join("/");
30
+ }
20
31
  function destinationRef(identity, title) {
21
32
  const slug = slugifyPrototypeTitle(title);
22
33
  if (slug === null)
@@ -31,21 +42,72 @@ class PrototypeMetadataFileError extends Error {
31
42
  }
32
43
  class DeploymentUnavailableError extends Error {
33
44
  }
45
+ class DeploymentSourcePushError extends Error {
46
+ }
47
+ class DeploymentNotAcceptedError extends Error {
48
+ }
49
+ const pendingPublications = new Map();
50
+ function pendingPublicationKey(repositoryRoot, login) {
51
+ return `${repositoryRoot}\0${login}`;
52
+ }
34
53
  function metadataPath(runtime, ref) {
35
54
  return join(runtime.prototypeRoot, ref.login, ref.slug, "metadata.json");
36
55
  }
37
- async function assertPrototypeDirectory(runtime, ref) {
38
- const paths = [
56
+ function prototypeDirectoryPaths(runtime, ref) {
57
+ return [
39
58
  runtime.prototypeRoot,
40
59
  join(runtime.prototypeRoot, ref.login),
41
60
  join(runtime.prototypeRoot, ref.login, ref.slug),
42
61
  ];
43
- try {
44
- for (const path of paths) {
62
+ }
63
+ async function capturePrototypeDirectory(runtime, ref) {
64
+ const entries = [];
65
+ for (const path of prototypeDirectoryPaths(runtime, ref)) {
66
+ try {
45
67
  const stats = await lstat(path);
46
- if (stats.isSymbolicLink() || !stats.isDirectory()) {
47
- throw new PrototypeMetadataFileError();
68
+ entries.push({
69
+ path,
70
+ kind: stats.isSymbolicLink()
71
+ ? "symlink"
72
+ : stats.isDirectory()
73
+ ? "directory"
74
+ : "other",
75
+ dev: stats.dev,
76
+ ino: stats.ino,
77
+ });
78
+ }
79
+ catch (error) {
80
+ if (error &&
81
+ typeof error === "object" &&
82
+ "code" in error &&
83
+ error.code === "ENOENT") {
84
+ entries.push({ path, kind: "missing" });
85
+ continue;
48
86
  }
87
+ throw new PrototypeMetadataFileError();
88
+ }
89
+ }
90
+ return entries;
91
+ }
92
+ function samePrototypeDirectorySnapshot(expected, actual) {
93
+ return expected.every((entry, index) => {
94
+ const current = actual[index];
95
+ return (current !== undefined &&
96
+ current.path === entry.path &&
97
+ current.kind === entry.kind &&
98
+ current.dev === entry.dev &&
99
+ current.ino === entry.ino);
100
+ });
101
+ }
102
+ async function assertPrototypeDirectory(runtime, ref, expected) {
103
+ try {
104
+ const actual = await capturePrototypeDirectory(runtime, ref);
105
+ if (expected !== undefined &&
106
+ !samePrototypeDirectorySnapshot(expected, actual)) {
107
+ throw new PrototypeMetadataFileError();
108
+ }
109
+ if (actual.some((entry) => entry.kind !== "directory")) {
110
+ throw new PrototypeMetadataFileError();
49
111
  }
50
112
  }
51
113
  catch (error) {
@@ -79,18 +141,6 @@ async function readMetadataDocument(runtime, ref, themeRegistry) {
79
141
  throw new PrototypeMetadataFileError();
80
142
  }
81
143
  }
82
- async function removePrototypeDirectory(runtime, ref) {
83
- await assertPrototypeDirectory(runtime, ref);
84
- try {
85
- await rm(join(runtime.prototypeRoot, ref.login, ref.slug), {
86
- force: false,
87
- recursive: true,
88
- });
89
- }
90
- catch {
91
- throw new PrototypeMetadataFileError();
92
- }
93
- }
94
144
  async function writeMetadataDocument(runtime, ref, document) {
95
145
  const path = metadataPath(runtime, ref);
96
146
  const temporaryPath = `${path}.${randomUUID()}.tmp`;
@@ -114,26 +164,6 @@ async function writeMetadataDocument(runtime, ref, document) {
114
164
  await rm(temporaryPath, { force: true }).catch(() => undefined);
115
165
  }
116
166
  }
117
- async function mergeIdentityBranchToPrimary(repository, options) {
118
- if (options.branch === options.primaryBranch)
119
- return;
120
- const hasRemote = (await repository.remotes()).some((remote) => remote.name === options.remote);
121
- if (!hasRemote)
122
- return;
123
- await repository.checkout([options.primaryBranch]);
124
- try {
125
- await repository.merge([
126
- options.branch,
127
- "--no-ff",
128
- "-m",
129
- `merge: ${options.branch}`,
130
- ]);
131
- await repository.push(options.remote, options.primaryBranch);
132
- }
133
- finally {
134
- await repository.checkout([options.branch]);
135
- }
136
- }
137
167
  function mapError(error) {
138
168
  if (error instanceof PrototypeNotFoundError)
139
169
  return "not-found";
@@ -175,6 +205,11 @@ function mapError(error) {
175
205
  if (error instanceof DeploymentUnavailableError) {
176
206
  return "deployment-unavailable";
177
207
  }
208
+ if (error instanceof DeploymentSourcePushError ||
209
+ error instanceof DeploymentNotAcceptedError ||
210
+ error instanceof DeploymentWorktreeError) {
211
+ return "deployment-failure";
212
+ }
178
213
  if (error instanceof DeploymentProviderError) {
179
214
  return error.kind === "timeout" ? "timeout" : "deployment-failure";
180
215
  }
@@ -185,6 +220,15 @@ function resultFrom(operation) {
185
220
  .then(lifecycleSuccess)
186
221
  .catch((error) => lifecycleFailure(mapError(error)));
187
222
  }
223
+ async function diagnoseMetadataStep(step, operation) {
224
+ try {
225
+ return await operation();
226
+ }
227
+ catch (error) {
228
+ reportDeploymentIssue("publication", step, error);
229
+ throw error;
230
+ }
231
+ }
188
232
  export class LocalPrototypeLifecycle {
189
233
  #runtime;
190
234
  gitMode;
@@ -194,9 +238,14 @@ export class LocalPrototypeLifecycle {
194
238
  #deploymentProvider;
195
239
  #applicationId;
196
240
  #themeRegistry;
241
+ #publishDeploymentSource;
242
+ #installDependencies;
243
+ #restartRequired = false;
244
+ #preparationAttention = new Map();
197
245
  constructor(options) {
198
246
  const internalOptions = options;
199
- const runtime = internalOptions.runtime ?? createPrototypeLifecycleRuntime(internalOptions);
247
+ const runtime = internalOptions.runtime ??
248
+ createPrototypeLifecycleRuntime(internalOptions);
200
249
  this.#runtime = {
201
250
  ...runtime,
202
251
  gitMode: parseGitModeSetting(runtime.gitMode),
@@ -214,6 +263,11 @@ export class LocalPrototypeLifecycle {
214
263
  internalOptions.preparePrototypeFiles ?? (() => undefined);
215
264
  this.#deploymentProvider = internalOptions.deploymentProvider;
216
265
  this.#applicationId = internalOptions.applicationId;
266
+ this.#publishDeploymentSource =
267
+ internalOptions.publishDeploymentSource === null
268
+ ? null
269
+ : (internalOptions.publishDeploymentSource ?? publishDeploymentSource);
270
+ this.#installDependencies = options.installDependencies;
217
271
  if (this.#deploymentProvider &&
218
272
  (typeof this.#applicationId !== "string" ||
219
273
  this.#applicationId.length === 0)) {
@@ -230,22 +284,27 @@ export class LocalPrototypeLifecycle {
230
284
  }
231
285
  return branch;
232
286
  }
233
- async #ownedPrototype(identity, input) {
287
+ async #authorizedPrototype(identity, input) {
234
288
  const verifiedIdentity = await this.#verifiedIdentity(identity);
235
289
  const normalizedInput = parsePrototypeCommandInput(input);
236
290
  if (normalizedInput.ref.login !== verifiedIdentity.login) {
237
291
  throw new PrototypeOwnershipError();
238
292
  }
239
- const summary = await this.#catalog.getSummary(normalizedInput.ref);
240
- if (!summary) {
241
- throw new PrototypeNotFoundError();
242
- }
293
+ const directorySnapshot = await capturePrototypeDirectory(this.#runtime, normalizedInput.ref);
243
294
  return {
244
295
  ref: normalizedInput.ref,
245
296
  path: repositoryRelativePath(this.#runtime, normalizedInput.ref),
246
- summary,
297
+ directorySnapshot,
247
298
  };
248
299
  }
300
+ async #ownedPrototype(identity, input) {
301
+ const authorized = await this.#authorizedPrototype(identity, input);
302
+ const summary = await this.#catalog.getSummary(authorized.ref);
303
+ if (!summary) {
304
+ throw new PrototypeNotFoundError();
305
+ }
306
+ return { ...authorized, summary };
307
+ }
249
308
  async #prepareMutationBranch(login) {
250
309
  await assertIdentityBranchReady(this.#repository, login);
251
310
  return login;
@@ -280,26 +339,160 @@ export class LocalPrototypeLifecycle {
280
339
  if (this.#runtime.gitMode === "manual" ||
281
340
  this.#runtime.automaticBranchSwitch === "disabled") {
282
341
  await this.#currentBranch();
283
- return { status: "disabled" };
342
+ const disabled = {
343
+ status: "disabled",
344
+ effects: {
345
+ changed: false,
346
+ pushed: false,
347
+ reconciledPaths: [],
348
+ restartRequired: false,
349
+ },
350
+ };
351
+ this.#preparationAttention.set(verifiedIdentity.login, disabled);
352
+ return disabled;
284
353
  }
285
354
  const synchronization = await synchronizeIdentityBranch(this.#repository, {
286
355
  identityBranch: verifiedIdentity.login,
287
356
  primaryBranch: this.#runtime.primaryBranch,
288
357
  remote: this.#runtime.remote,
358
+ prototypeRoot: relative(this.#runtime.repositoryRoot, this.#runtime.prototypeRoot)
359
+ .split(sep)
360
+ .join("/"),
289
361
  });
290
- if (synchronization.status !== "synchronized") {
291
- return {
292
- status: synchronization.status,
362
+ const reconciledPaths = synchronization.status === "synchronized" &&
363
+ synchronization.platformPaths.length === 0
364
+ ? synchronization.affectedPaths.filter((path) => this.#runtime.managedRepositoryPaths.includes(path))
365
+ : synchronization.platformPaths;
366
+ const effects = {
367
+ changed: synchronization.changed,
368
+ pushed: synchronization.pushed,
369
+ reconciledPaths,
370
+ restartRequired: synchronization.status === "synchronized" &&
371
+ synchronization.platformPaths.length === 0
372
+ ? reconciledPaths.length > 0
373
+ : synchronization.restartRequired,
374
+ ...(synchronization.platformPaths.length > 0
375
+ ? { platformPaths: synchronization.platformPaths }
376
+ : {}),
377
+ ...(synchronization.dependencyPaths.length > 0
378
+ ? { dependencyPaths: synchronization.dependencyPaths }
379
+ : {}),
380
+ ...(synchronization.preparationRequired
381
+ ? { preparationRequired: true }
382
+ : {}),
383
+ };
384
+ if (effects.restartRequired)
385
+ this.#restartRequired = true;
386
+ if (effects.preparationRequired) {
387
+ await writePreparationMarker(this.#repository, this.#runtime.repositoryRoot, effects.dependencyPaths ?? []);
388
+ }
389
+ if (synchronization.status === "deferred") {
390
+ const attention = {
391
+ status: "deferred",
392
+ reason: synchronization.reason,
393
+ effects,
394
+ ...(synchronization.affectedPaths.length > 0
395
+ ? { affectedPaths: synchronization.affectedPaths }
396
+ : {}),
397
+ };
398
+ this.#preparationAttention.set(verifiedIdentity.login, attention);
399
+ return attention;
400
+ }
401
+ if (synchronization.status === "unsafe") {
402
+ const attention = {
403
+ status: "unsafe",
293
404
  reason: synchronization.reason,
405
+ effects,
294
406
  ...(synchronization.affectedPaths.length > 0
295
407
  ? { affectedPaths: synchronization.affectedPaths }
296
408
  : {}),
297
409
  };
410
+ this.#preparationAttention.set(verifiedIdentity.login, attention);
411
+ return attention;
298
412
  }
299
- return { status: "ready" };
413
+ const ready = {
414
+ status: "ready",
415
+ effects,
416
+ ...(synchronization.affectedPaths.length > 0
417
+ ? { affectedPaths: synchronization.affectedPaths }
418
+ : {}),
419
+ };
420
+ this.#preparationAttention.delete(verifiedIdentity.login);
421
+ return ready;
300
422
  });
301
423
  });
302
424
  }
425
+ async prepareDependencies(dependencyPaths = [], installer) {
426
+ return resultFrom(async () => withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
427
+ let pending = null;
428
+ try {
429
+ pending = await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot);
430
+ }
431
+ catch {
432
+ // A retained/corrupt marker is recovery truth. Restricted modes must
433
+ // preserve it without attempting installation; managed mode must
434
+ // report not-ready rather than guessing what to install.
435
+ if (this.#runtime.gitMode === "manual" ||
436
+ this.#runtime.automaticBranchSwitch === "disabled") {
437
+ return {
438
+ status: "skipped",
439
+ dependencyPaths: [...dependencyPaths],
440
+ pending: true,
441
+ restartRequired: false,
442
+ };
443
+ }
444
+ return {
445
+ status: "failed",
446
+ dependencyPaths: [...dependencyPaths],
447
+ pending: true,
448
+ restartRequired: true,
449
+ };
450
+ }
451
+ const paths = [...new Set([...(pending ?? []), ...dependencyPaths])];
452
+ if (this.#runtime.gitMode === "manual" ||
453
+ this.#runtime.automaticBranchSwitch === "disabled") {
454
+ return {
455
+ status: "skipped",
456
+ dependencyPaths: paths,
457
+ pending: pending !== null,
458
+ restartRequired: false,
459
+ };
460
+ }
461
+ // An empty path list is still an obligation when the marker exists.
462
+ // Do not reinterpret a retained marker as "not required".
463
+ if (paths.length === 0 && pending === null) {
464
+ return {
465
+ status: "not-required",
466
+ dependencyPaths: [],
467
+ pending: false,
468
+ restartRequired: false,
469
+ };
470
+ }
471
+ await writePreparationMarker(this.#repository, this.#runtime.repositoryRoot, paths);
472
+ try {
473
+ await (installer ??
474
+ this.#installDependencies ??
475
+ (async (root) => {
476
+ await execFile("pnpm", ["install"], { cwd: root });
477
+ }))(this.#runtime.repositoryRoot);
478
+ }
479
+ catch {
480
+ return {
481
+ status: "failed",
482
+ dependencyPaths: paths,
483
+ pending: true,
484
+ restartRequired: true,
485
+ };
486
+ }
487
+ await clearPreparationMarker(this.#repository, this.#runtime.repositoryRoot);
488
+ return {
489
+ status: "prepared",
490
+ dependencyPaths: paths,
491
+ pending: false,
492
+ restartRequired: true,
493
+ };
494
+ }));
495
+ }
303
496
  async create(identity, input) {
304
497
  return resultFrom(async () => {
305
498
  const verifiedIdentity = await this.#verifiedIdentity(identity);
@@ -339,11 +532,13 @@ export class LocalPrototypeLifecycle {
339
532
  };
340
533
  }
341
534
  await commitPath(this.#repository, repositoryRelativePath(this.#runtime, ref), `init: ${ref.slug}`);
342
- const push = await pushBranch(this.#repository, this.#runtime.remote, branch);
535
+ const sourceSha = await this.#headCommit();
536
+ const outcome = await this.#publishOrdinaryNamespace(verifiedIdentity, "create", ref, repositoryRelativePath(this.#runtime, ref), sourceSha);
343
537
  return {
344
538
  ref,
345
539
  url: getPrototypeUrl(ref),
346
- remoteStatus: push.remoteStatus,
540
+ remoteStatus: "local-only",
541
+ outcome,
347
542
  };
348
543
  });
349
544
  });
@@ -389,16 +584,34 @@ export class LocalPrototypeLifecycle {
389
584
  const verifiedIdentity = await this.#verifiedIdentity(identity);
390
585
  const normalizedInput = parsePrototypeCommandInput(input);
391
586
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
392
- const { ref, path } = await this.#ownedPrototype(verifiedIdentity, normalizedInput);
587
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, normalizedInput);
588
+ if (directorySnapshot.at(-1)?.kind === "missing") {
589
+ throw new PrototypeNotFoundError();
590
+ }
393
591
  const branch = this.#runtime.gitMode === "managed"
394
592
  ? await this.#prepareMutationBranch(verifiedIdentity.login)
395
593
  : undefined;
396
- await removePrototypeDirectory(this.#runtime, ref);
594
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
595
+ await deletePrototypePath(this.#repository, path, branch === undefined ? undefined : `delete: ${ref.slug}`);
397
596
  if (branch !== undefined) {
398
- await commitPath(this.#repository, path, `delete: ${ref.slug}`);
399
- await pushBranch(this.#repository, this.#runtime.remote, branch);
597
+ const sourceSha = await this.#headCommit();
598
+ const outcome = await this.#publishOrdinaryNamespace(verifiedIdentity, "delete", ref, path, sourceSha);
599
+ return {
600
+ deleted: true,
601
+ ref,
602
+ outcome,
603
+ };
400
604
  }
401
- return { deleted: true, ref };
605
+ return {
606
+ deleted: true,
607
+ ref,
608
+ outcome: {
609
+ local: { status: "completed" },
610
+ upload: { status: "not-attempted" },
611
+ preview: { status: "not-attempted" },
612
+ publication: { status: "not-attempted" },
613
+ },
614
+ };
402
615
  });
403
616
  });
404
617
  }
@@ -417,7 +630,7 @@ export class LocalPrototypeLifecycle {
417
630
  async getRepositorySummary(identity) {
418
631
  return resultFrom(async () => {
419
632
  const verifiedIdentity = await this.#verifiedIdentity(identity);
420
- return getRepositorySummary({
633
+ return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => getRepositorySummary({
421
634
  repository: this.#repository,
422
635
  catalog: this.#catalog,
423
636
  repositoryRoot: this.#runtime.repositoryRoot,
@@ -426,13 +639,16 @@ export class LocalPrototypeLifecycle {
426
639
  mode: this.#runtime.gitMode,
427
640
  remote: this.#runtime.remote,
428
641
  primaryBranch: this.#runtime.primaryBranch,
429
- });
642
+ restartRequired: this.#restartRequired,
643
+ preparationAttention: this.#preparationAttention.get(verifiedIdentity.login),
644
+ preparationPending: (await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot)) !== null,
645
+ }));
430
646
  });
431
647
  }
432
648
  async checkRepositorySummary(identity) {
433
649
  return resultFrom(async () => {
434
650
  const verifiedIdentity = await this.#verifiedIdentity(identity);
435
- return getRepositorySummary({
651
+ return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => getRepositorySummary({
436
652
  repository: this.#repository,
437
653
  catalog: this.#catalog,
438
654
  repositoryRoot: this.#runtime.repositoryRoot,
@@ -441,10 +657,149 @@ export class LocalPrototypeLifecycle {
441
657
  mode: this.#runtime.gitMode,
442
658
  remote: this.#runtime.remote,
443
659
  primaryBranch: this.#runtime.primaryBranch,
660
+ restartRequired: this.#restartRequired,
661
+ preparationAttention: this.#preparationAttention.get(verifiedIdentity.login),
662
+ preparationPending: (await readPreparationMarker(this.#repository, this.#runtime.repositoryRoot)) !== null,
444
663
  refresh: true,
445
- });
664
+ }));
446
665
  });
447
666
  }
667
+ async #headCommit() {
668
+ const commit = (await this.#repository.raw(["rev-parse", "HEAD"])).trim();
669
+ if (!/^[0-9a-f]{40,64}$/iu.test(commit)) {
670
+ throw new GitRepositoryError("malformed-output", "head-commit");
671
+ }
672
+ return commit.toLowerCase();
673
+ }
674
+ #workspaceRuntime(workspacePath, workspaceRepository) {
675
+ return {
676
+ ...this.#runtime,
677
+ repositoryRoot: workspacePath,
678
+ repositoryRealPath: workspacePath,
679
+ prototypeRoot: join(workspacePath, relative(this.#runtime.repositoryRoot, this.#runtime.prototypeRoot)),
680
+ gitRepository: workspaceRepository,
681
+ };
682
+ }
683
+ #pendingKey(login) {
684
+ return pendingPublicationKey(this.#runtime.repositoryRoot, login);
685
+ }
686
+ #rememberPending(pending) {
687
+ pendingPublications.set(this.#pendingKey(pending.identity.login), pending);
688
+ }
689
+ #clearPending(login) {
690
+ pendingPublications.delete(this.#pendingKey(login));
691
+ }
692
+ #recoveryValue(pending, outcome) {
693
+ return {
694
+ recoveryId: pending.recoveryId,
695
+ operation: pending.operation,
696
+ kind: pending.kind,
697
+ ref: pending.ref,
698
+ sourceSha: pending.sourceSha,
699
+ ...(pending.previewUrl === undefined
700
+ ? {}
701
+ : { previewUrl: pending.previewUrl }),
702
+ ...(pending.deploymentId === undefined
703
+ ? {}
704
+ : { deploymentId: pending.deploymentId }),
705
+ ...(pending.readyState === undefined
706
+ ? {}
707
+ : { readyState: pending.readyState }),
708
+ ...(pending.acceptedAt === undefined
709
+ ? {}
710
+ : { acceptedAt: pending.acceptedAt }),
711
+ outcome,
712
+ };
713
+ }
714
+ async #publishCommittedNamespace(identity, sourceSha) {
715
+ if (this.#publishDeploymentSource === null) {
716
+ throw new DeploymentUnavailableError();
717
+ }
718
+ return this.#publishDeploymentSource(this.#repository, {
719
+ remote: this.#runtime.remote,
720
+ primaryBranch: this.#runtime.primaryBranch,
721
+ sourceSha,
722
+ namespacePath: namespaceRelativePath(this.#runtime, identity.login),
723
+ commitIdentity: this.#runtime.commitIdentity,
724
+ }, async () => undefined);
725
+ }
726
+ async #publishOrdinaryNamespace(identity, operation, ref, path, sourceSha) {
727
+ const baseOutcome = {
728
+ local: { status: "completed", checkpointSha: sourceSha },
729
+ upload: { status: "not-attempted", sourceSha },
730
+ preview: { status: "not-attempted" },
731
+ publication: {
732
+ status: this.#publishDeploymentSource === null ? "not-attempted" : "pending",
733
+ },
734
+ };
735
+ if (this.#publishDeploymentSource === null)
736
+ return baseOutcome;
737
+ try {
738
+ const publication = await this.#publishCommittedNamespace(identity, sourceSha);
739
+ this.#clearPending(identity.login);
740
+ return {
741
+ ...baseOutcome,
742
+ publication: {
743
+ status: "confirmed",
744
+ revision: publication.publicationSha,
745
+ },
746
+ };
747
+ }
748
+ catch {
749
+ const pending = {
750
+ recoveryId: randomUUID(),
751
+ operation,
752
+ kind: "ordinary-publication",
753
+ identity,
754
+ ref,
755
+ path,
756
+ sourceSha,
757
+ };
758
+ this.#rememberPending(pending);
759
+ return {
760
+ ...baseOutcome,
761
+ publication: {
762
+ status: "failed",
763
+ recoveryId: pending.recoveryId,
764
+ },
765
+ };
766
+ }
767
+ }
768
+ async #publishDeploymentMetadata(pending) {
769
+ if (this.#publishDeploymentSource === null) {
770
+ throw new DeploymentUnavailableError();
771
+ }
772
+ return this.#publishDeploymentSource(this.#repository, {
773
+ remote: this.#runtime.remote,
774
+ primaryBranch: this.#runtime.primaryBranch,
775
+ sourceSha: pending.sourceSha,
776
+ namespacePath: namespaceRelativePath(this.#runtime, pending.identity.login),
777
+ commitIdentity: this.#runtime.commitIdentity,
778
+ }, async ({ path, repository }) => {
779
+ const workspaceRuntime = this.#workspaceRuntime(path, repository);
780
+ const mergedDocument = await diagnoseMetadataStep("metadata-read", () => readMetadataDocument(workspaceRuntime, pending.ref, this.#themeRegistry));
781
+ await diagnoseMetadataStep("metadata-write", () => writeMetadataDocument(workspaceRuntime, pending.ref, {
782
+ ...mergedDocument,
783
+ previewUrl: pending.previewUrl,
784
+ updatedAt: pending.acceptedAt,
785
+ }));
786
+ await diagnoseMetadataStep("metadata-commit", () => commitPath(repository, pending.path, `deploy: ${pending.ref.slug} (metadata)`));
787
+ });
788
+ }
789
+ async #deploymentValue(pending, publicationWarnings = [], outcome) {
790
+ const warnings = [...publicationWarnings];
791
+ return {
792
+ deployed: true,
793
+ ref: pending.ref,
794
+ previewUrl: pending.previewUrl,
795
+ deploymentId: pending.deploymentId,
796
+ sourceSha: pending.sourceSha,
797
+ readyState: pending.readyState,
798
+ ...(outcome === undefined ? {} : { outcome }),
799
+ warning: publicationWarnings[0],
800
+ ...(warnings.length === 0 ? {} : { warnings }),
801
+ };
802
+ }
448
803
  async deploy(identity, input) {
449
804
  if (this.#runtime.gitMode === "manual") {
450
805
  return lifecycleFailure("forbidden");
@@ -457,7 +812,7 @@ export class LocalPrototypeLifecycle {
457
812
  }
458
813
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
459
814
  const { ref, path } = await this.#ownedPrototype(verifiedIdentity, normalizedInput);
460
- const branch = await this.#prepareMutationBranch(verifiedIdentity.login);
815
+ await assertIdentityBranchReady(this.#repository, verifiedIdentity.login);
461
816
  await assertPrototypeDirectory(this.#runtime, ref);
462
817
  const document = await readMetadataDocument(this.#runtime, ref, this.#themeRegistry);
463
818
  const metadata = parsePrototypeMetadata(document, {
@@ -466,60 +821,344 @@ export class LocalPrototypeLifecycle {
466
821
  if (metadata.authorLogin !== ref.login) {
467
822
  throw new PrototypeNotFoundError();
468
823
  }
469
- await commitPath(this.#repository, path, `deploy: ${ref.slug}`);
470
- await pushBranch(this.#repository, this.#runtime.remote, branch);
471
- const deployment = await this.#deploymentProvider.deploy({
472
- applicationId: this.#applicationId,
473
- login: verifiedIdentity.login,
474
- });
475
- const previewUrl = new URL(getPrototypeUrl(ref).slice(1), `${deployment.url.replace(/\/+$/, "")}/`).toString();
476
- let warning;
824
+ await commitPrototypePath(this.#repository, path, `deploy: ${ref.slug}`);
825
+ const sourceSha = await this.#headCommit();
826
+ let sourcePush;
477
827
  try {
478
- await writeMetadataDocument(this.#runtime, ref, {
479
- ...document,
480
- previewUrl,
481
- updatedAt: new Date().toISOString(),
482
- });
483
- await commitPath(this.#repository, path, `deploy: ${ref.slug} (metadata)`);
484
- await pushBranch(this.#repository, this.#runtime.remote, branch);
485
- }
486
- catch {
487
- warning = "metadata-persistence-failed";
828
+ sourcePush = await pushCommitToBranch(this.#repository, this.#runtime.remote, sourceSha, verifiedIdentity.login, true);
488
829
  }
489
- if (warning === undefined) {
490
- try {
491
- await mergeIdentityBranchToPrimary(this.#repository, {
492
- branch,
493
- primaryBranch: this.#runtime.primaryBranch,
494
- remote: this.#runtime.remote,
495
- });
830
+ catch (error) {
831
+ if (error instanceof GitRepositoryError) {
832
+ const pending = {
833
+ recoveryId: randomUUID(),
834
+ operation: "deploy",
835
+ kind: "deployment-upload",
836
+ identity: verifiedIdentity,
837
+ ref,
838
+ path,
839
+ sourceSha,
840
+ };
841
+ this.#rememberPending(pending);
842
+ return {
843
+ deployed: false,
844
+ ref,
845
+ sourceSha,
846
+ warning: "upload-unconfirmed",
847
+ outcome: {
848
+ local: { status: "completed", checkpointSha: sourceSha },
849
+ upload: {
850
+ status: "unconfirmed",
851
+ sourceSha,
852
+ },
853
+ preview: { status: "not-attempted" },
854
+ publication: {
855
+ status: "pending",
856
+ recoveryId: pending.recoveryId,
857
+ },
858
+ },
859
+ };
496
860
  }
497
- catch {
498
- warning = "primary-merge-failed";
861
+ throw error;
862
+ }
863
+ if (sourcePush.remoteStatus !== "pushed") {
864
+ throw new DeploymentSourcePushError();
865
+ }
866
+ let deployment;
867
+ try {
868
+ deployment = await this.#deploymentProvider.deploy({
869
+ applicationId: this.#applicationId,
870
+ login: verifiedIdentity.login,
871
+ });
872
+ }
873
+ catch (error) {
874
+ if (error instanceof DeploymentProviderError &&
875
+ [
876
+ "timeout",
877
+ "transport-failure",
878
+ "invalid-response",
879
+ "invalid-url",
880
+ ].includes(error.kind)) {
881
+ const pending = {
882
+ recoveryId: randomUUID(),
883
+ operation: "deploy",
884
+ kind: "deployment-preview-unconfirmed",
885
+ identity: verifiedIdentity,
886
+ ref,
887
+ path,
888
+ sourceSha,
889
+ };
890
+ this.#rememberPending(pending);
891
+ return {
892
+ deployed: false,
893
+ ref,
894
+ sourceSha,
895
+ warning: "preview-unconfirmed",
896
+ outcome: {
897
+ local: { status: "completed", checkpointSha: sourceSha },
898
+ upload: { status: "confirmed", sourceSha },
899
+ preview: { status: "unconfirmed" },
900
+ publication: { status: "not-attempted" },
901
+ },
902
+ };
499
903
  }
904
+ throw error;
500
905
  }
501
- return {
502
- deployed: true,
906
+ if (deployment.commitSha.toLowerCase() !== sourceSha ||
907
+ !["READY", "QUEUED", "INITIALIZING", "BUILDING"].includes(deployment.readyState)) {
908
+ throw new DeploymentNotAcceptedError();
909
+ }
910
+ const previewUrl = new URL(getPrototypeUrl(ref).slice(1), `${deployment.url.replace(/\/+$/, "")}/`).toString();
911
+ const pending = {
912
+ recoveryId: randomUUID(),
913
+ operation: "deploy",
914
+ kind: "deployment-publication",
915
+ identity: verifiedIdentity,
503
916
  ref,
917
+ path,
504
918
  previewUrl,
505
919
  deploymentId: deployment.deploymentId,
920
+ sourceSha,
921
+ acceptedAt: new Date().toISOString(),
506
922
  readyState: deployment.readyState,
507
- warning,
508
923
  };
924
+ const acceptedOutcome = {
925
+ local: { status: "completed", checkpointSha: sourceSha },
926
+ upload: { status: "confirmed", sourceSha },
927
+ preview: {
928
+ status: "accepted",
929
+ previewUrl,
930
+ deploymentId: deployment.deploymentId,
931
+ },
932
+ publication: { status: "pending" },
933
+ };
934
+ try {
935
+ const publication = await this.#publishDeploymentMetadata(pending);
936
+ this.#clearPending(verifiedIdentity.login);
937
+ return this.#deploymentValue(pending, publication.cleanupFailed ? ["publication-cleanup-failed"] : [], {
938
+ ...acceptedOutcome,
939
+ publication: {
940
+ status: "confirmed",
941
+ revision: publication.publicationSha,
942
+ },
943
+ });
944
+ }
945
+ catch (error) {
946
+ // Keep the accepted deployment retryable for every publication
947
+ // failure, including metadata preparation failures.
948
+ this.#rememberPending(pending);
949
+ return this.#deploymentValue(pending, [
950
+ error instanceof DeploymentWorktreeError &&
951
+ error.kind !== "cleanup-failure" &&
952
+ error.kind !== "metadata-failure"
953
+ ? "primary-merge-failed"
954
+ : "metadata-persistence-failed",
955
+ ...(error instanceof DeploymentWorktreeError &&
956
+ error.cleanupFailed
957
+ ? ["publication-cleanup-failed"]
958
+ : []),
959
+ ], {
960
+ ...acceptedOutcome,
961
+ publication: {
962
+ status: "failed",
963
+ recoveryId: pending.recoveryId,
964
+ },
965
+ });
966
+ }
967
+ });
968
+ });
969
+ }
970
+ async getPendingRecovery(identity) {
971
+ return resultFrom(async () => {
972
+ const verifiedIdentity = await this.#verifiedIdentity(identity);
973
+ const pending = pendingPublications.get(this.#pendingKey(verifiedIdentity.login));
974
+ if (pending === undefined)
975
+ return null;
976
+ return this.#recoveryValue(pending, {
977
+ local: { status: "completed", checkpointSha: pending.sourceSha },
978
+ upload: {
979
+ status: pending.kind === "deployment-upload" ? "unconfirmed" : "confirmed",
980
+ sourceSha: pending.sourceSha,
981
+ },
982
+ preview: pending.kind === "deployment-publication"
983
+ ? {
984
+ status: "accepted",
985
+ previewUrl: pending.previewUrl,
986
+ deploymentId: pending.deploymentId,
987
+ }
988
+ : pending.kind === "deployment-preview-unconfirmed"
989
+ ? { status: "unconfirmed" }
990
+ : { status: "not-attempted" },
991
+ publication: {
992
+ status: pending.kind === "deployment-upload"
993
+ ? "pending"
994
+ : pending.kind === "deployment-preview-unconfirmed"
995
+ ? "not-attempted"
996
+ : "failed",
997
+ ...(pending.kind === "deployment-preview-unconfirmed"
998
+ ? {}
999
+ : { recoveryId: pending.recoveryId }),
1000
+ },
509
1001
  });
510
1002
  });
511
1003
  }
1004
+ async retryPendingPublication(identity, recoveryId) {
1005
+ if (this.#runtime.gitMode === "manual") {
1006
+ return lifecycleFailure("forbidden");
1007
+ }
1008
+ return resultFrom(async () => {
1009
+ const verifiedIdentity = await this.#verifiedIdentity(identity);
1010
+ const pending = pendingPublications.get(this.#pendingKey(verifiedIdentity.login));
1011
+ if (pending === undefined || pending.recoveryId !== recoveryId) {
1012
+ throw new DeploymentUnavailableError();
1013
+ }
1014
+ return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
1015
+ await assertIdentityBranchReady(this.#repository, verifiedIdentity.login);
1016
+ if (pending.kind === "deployment-preview-unconfirmed") {
1017
+ throw new DeploymentUnavailableError();
1018
+ }
1019
+ let deploymentPending = pending;
1020
+ if (pending.kind === "ordinary-publication") {
1021
+ deploymentPending = {
1022
+ ...pending,
1023
+ sourceSha: await this.#headCommit(),
1024
+ };
1025
+ this.#rememberPending(deploymentPending);
1026
+ }
1027
+ if (pending.kind === "deployment-upload") {
1028
+ const remoteSource = await remoteBranchCommit(this.#repository, this.#runtime.remote, verifiedIdentity.login);
1029
+ if (remoteSource !== pending.sourceSha) {
1030
+ const upload = await pushCommitToBranch(this.#repository, this.#runtime.remote, pending.sourceSha, verifiedIdentity.login, true);
1031
+ if (upload.remoteStatus !== "pushed") {
1032
+ throw new DeploymentSourcePushError();
1033
+ }
1034
+ }
1035
+ if (!this.#deploymentProvider) {
1036
+ throw new DeploymentUnavailableError();
1037
+ }
1038
+ let deployment;
1039
+ try {
1040
+ deployment = await this.#deploymentProvider.deploy({
1041
+ applicationId: this.#applicationId,
1042
+ login: verifiedIdentity.login,
1043
+ });
1044
+ }
1045
+ catch (error) {
1046
+ if (error instanceof DeploymentProviderError &&
1047
+ [
1048
+ "timeout",
1049
+ "transport-failure",
1050
+ "invalid-response",
1051
+ "invalid-url",
1052
+ ].includes(error.kind)) {
1053
+ const uncertain = {
1054
+ ...pending,
1055
+ kind: "deployment-preview-unconfirmed",
1056
+ };
1057
+ this.#rememberPending(uncertain);
1058
+ return this.#recoveryValue(uncertain, {
1059
+ local: {
1060
+ status: "completed",
1061
+ checkpointSha: pending.sourceSha,
1062
+ },
1063
+ upload: {
1064
+ status: "confirmed",
1065
+ sourceSha: pending.sourceSha,
1066
+ },
1067
+ preview: { status: "unconfirmed" },
1068
+ publication: { status: "not-attempted" },
1069
+ });
1070
+ }
1071
+ throw error;
1072
+ }
1073
+ if (deployment.commitSha.toLowerCase() !== pending.sourceSha ||
1074
+ !["READY", "QUEUED", "INITIALIZING", "BUILDING"].includes(deployment.readyState)) {
1075
+ throw new DeploymentNotAcceptedError();
1076
+ }
1077
+ deploymentPending = {
1078
+ ...pending,
1079
+ kind: "deployment-publication",
1080
+ previewUrl: new URL(getPrototypeUrl(pending.ref).slice(1), `${deployment.url.replace(/\/+$/, "")}/`).toString(),
1081
+ deploymentId: deployment.deploymentId,
1082
+ acceptedAt: new Date().toISOString(),
1083
+ readyState: deployment.readyState,
1084
+ };
1085
+ this.#rememberPending(deploymentPending);
1086
+ }
1087
+ const publication = deploymentPending.kind === "deployment-publication"
1088
+ ? await this.#publishDeploymentMetadata(deploymentPending)
1089
+ : await this.#publishCommittedNamespace(verifiedIdentity, deploymentPending.sourceSha);
1090
+ this.#clearPending(verifiedIdentity.login);
1091
+ return this.#recoveryValue(deploymentPending, {
1092
+ local: {
1093
+ status: "completed",
1094
+ checkpointSha: deploymentPending.sourceSha,
1095
+ },
1096
+ upload: {
1097
+ status: "confirmed",
1098
+ sourceSha: deploymentPending.sourceSha,
1099
+ },
1100
+ preview: deploymentPending.kind === "deployment-publication"
1101
+ ? {
1102
+ status: "accepted",
1103
+ previewUrl: deploymentPending.previewUrl,
1104
+ deploymentId: deploymentPending.deploymentId,
1105
+ }
1106
+ : { status: "not-attempted" },
1107
+ publication: {
1108
+ status: "confirmed",
1109
+ revision: publication.publicationSha,
1110
+ },
1111
+ });
1112
+ });
1113
+ });
1114
+ }
1115
+ /** Compatibility wrapper; recovery-ID retry is the implementation. */
1116
+ async retryPublish(identity, input) {
1117
+ if (this.#runtime.gitMode === "manual") {
1118
+ return lifecycleFailure("forbidden");
1119
+ }
1120
+ let normalizedInput;
1121
+ try {
1122
+ normalizedInput = parsePrototypeCommandInput(input);
1123
+ }
1124
+ catch {
1125
+ return lifecycleFailure("invalid-input");
1126
+ }
1127
+ const pending = pendingPublications.get(this.#pendingKey(identity.login));
1128
+ if (pending === undefined ||
1129
+ pending.kind !== "deployment-publication" ||
1130
+ pending.ref.login !== normalizedInput.ref.login ||
1131
+ pending.ref.slug !== normalizedInput.ref.slug) {
1132
+ return lifecycleFailure("deployment-unavailable");
1133
+ }
1134
+ const result = await this.retryPendingPublication(identity, pending.recoveryId);
1135
+ if (!result.ok)
1136
+ return result;
1137
+ const current = pendingPublications.get(this.#pendingKey(identity.login));
1138
+ const completed = {
1139
+ ...pending,
1140
+ ...(current ?? {}),
1141
+ sourceSha: result.value.sourceSha,
1142
+ ...(result.value.previewUrl === undefined
1143
+ ? {}
1144
+ : { previewUrl: result.value.previewUrl }),
1145
+ ...(result.value.deploymentId === undefined
1146
+ ? {}
1147
+ : { deploymentId: result.value.deploymentId }),
1148
+ };
1149
+ return lifecycleSuccess(await this.#deploymentValue(completed, [], result.value.outcome));
1150
+ }
512
1151
  async save(identity, input) {
513
1152
  if (this.#runtime.gitMode === "manual") {
514
1153
  return lifecycleFailure("forbidden");
515
1154
  }
516
1155
  return resultFrom(async () => {
517
1156
  const verifiedIdentity = await this.#verifiedIdentity(identity);
518
- const { ref, path } = await this.#ownedPrototype(verifiedIdentity, input);
519
1157
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
520
1158
  await this.#prepareMutationBranch(verifiedIdentity.login);
521
- await commitPath(this.#repository, path, `save: ${ref.slug}`);
522
- await pushBranch(this.#repository, this.#runtime.remote, verifiedIdentity.login);
1159
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, input);
1160
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
1161
+ await commitPrototypePath(this.#repository, path, `save: ${ref.slug}`);
523
1162
  return { saved: true };
524
1163
  });
525
1164
  });
@@ -530,10 +1169,11 @@ export class LocalPrototypeLifecycle {
530
1169
  }
531
1170
  return resultFrom(async () => {
532
1171
  const verifiedIdentity = await this.#verifiedIdentity(identity);
533
- const { path } = await this.#ownedPrototype(verifiedIdentity, input);
534
1172
  return withRepositoryOperation(this.#runtime.repositoryRealPath, async () => {
535
1173
  await this.#prepareMutationBranch(verifiedIdentity.login);
536
- await discardPath(this.#repository, path);
1174
+ const { ref, path, directorySnapshot } = await this.#authorizedPrototype(verifiedIdentity, input);
1175
+ await assertPrototypeDirectory(this.#runtime, ref, directorySnapshot);
1176
+ await discardPrototypePath(this.#repository, path);
537
1177
  return { discarded: true };
538
1178
  });
539
1179
  });