gencow 0.1.252 → 0.1.253

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.
package/core/index.js CHANGED
@@ -4,6 +4,78 @@ function defineApi(api) {
4
4
  }
5
5
 
6
6
  // ../core/src/http-route.ts
7
+ var discardedResponseSymbol = /* @__PURE__ */ Symbol.for("@gencow/core/http-route/discarded-response/v1");
8
+ var discardedResponseCauseSymbol = /* @__PURE__ */ Symbol.for("@gencow/core/http-route/discarded-response-cause/v1");
9
+ var outputValidationFailureSymbol = /* @__PURE__ */ Symbol.for("@gencow/core/http-route/output-validation-failure/v1");
10
+ function getHttpRouteDiscardedResponse(value) {
11
+ if (!value || typeof value !== "object" && typeof value !== "function") return void 0;
12
+ try {
13
+ const descriptor = Object.getOwnPropertyDescriptor(value, discardedResponseSymbol);
14
+ const response = descriptor && "value" in descriptor ? descriptor.value : void 0;
15
+ return response instanceof Response ? response : void 0;
16
+ } catch {
17
+ return void 0;
18
+ }
19
+ }
20
+ function getHttpRouteDiscardedResponseCause(value) {
21
+ if (!value || typeof value !== "object" && typeof value !== "function") return void 0;
22
+ try {
23
+ const descriptor = Object.getOwnPropertyDescriptor(value, discardedResponseCauseSymbol);
24
+ return descriptor && "value" in descriptor ? descriptor.value : void 0;
25
+ } catch {
26
+ return void 0;
27
+ }
28
+ }
29
+ function isHttpRouteOutputValidationFailure(value) {
30
+ if (!value || typeof value !== "object" && typeof value !== "function") return false;
31
+ try {
32
+ const descriptor = Object.getOwnPropertyDescriptor(value, outputValidationFailureSymbol);
33
+ return !!descriptor && "value" in descriptor && descriptor.value === true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+ function addDiscardedResponse(value, response) {
39
+ try {
40
+ Object.defineProperty(value, discardedResponseSymbol, {
41
+ configurable: false,
42
+ enumerable: false,
43
+ value: response,
44
+ writable: false
45
+ });
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ function routeResultResponse(value) {
52
+ if (value instanceof Response) return value;
53
+ return value?.body instanceof Response ? value.body : void 0;
54
+ }
55
+ function markDroppedResponse(value, response) {
56
+ if (!response || routeResultResponse(value) === response) return value;
57
+ if (value && (typeof value === "object" || typeof value === "function")) {
58
+ addDiscardedResponse(value, response);
59
+ }
60
+ return value;
61
+ }
62
+ function markDiscardedResponseError(error, response) {
63
+ if (error && (typeof error === "object" || typeof error === "function")) {
64
+ if (addDiscardedResponse(error, response)) return error;
65
+ }
66
+ const wrapped = error instanceof HttpRouteOutputValidationError ? new HttpRouteOutputValidationError(error.message) : new Error("httpRoute output validation failed", { cause: error });
67
+ addDiscardedResponse(wrapped, response);
68
+ try {
69
+ Object.defineProperty(wrapped, discardedResponseCauseSymbol, {
70
+ configurable: false,
71
+ enumerable: false,
72
+ value: error,
73
+ writable: false
74
+ });
75
+ } catch {
76
+ }
77
+ return wrapped;
78
+ }
7
79
  function hasStandardSchema(schema) {
8
80
  return !!schema && typeof schema === "object" && "~standard" in schema && !!schema["~standard"] && typeof schema["~standard"].validate === "function";
9
81
  }
@@ -65,10 +137,28 @@ async function buildInputFromRequest(method, req) {
65
137
  }
66
138
  async function maybeValidateRouteOutput(result, outputSchema) {
67
139
  if (!outputSchema) return result;
68
- const isSuccessStatus = result?.status === void 0 || result.status < 400;
69
- if (!isSuccessStatus) return result;
70
- const validatedBody = await validateOutput(outputSchema, result?.body);
71
- return { status: result?.status, headers: result?.headers, body: validatedBody };
140
+ const source = routeResultResponse(result);
141
+ try {
142
+ const isSuccessStatus = result instanceof Response ? result.status < 400 : result?.status === void 0 || result.status < 400;
143
+ if (!isSuccessStatus) return result;
144
+ const response = result instanceof Response ? result : void 0;
145
+ const validatedBody = await validateOutput(
146
+ outputSchema,
147
+ response ? response.body : result?.body
148
+ );
149
+ if (response) {
150
+ if (validatedBody === response.body) return result;
151
+ return markDroppedResponse(
152
+ { status: response.status, headers: response.headers, body: validatedBody },
153
+ source
154
+ );
155
+ }
156
+ const normalized = { status: result?.status, headers: result?.headers, body: validatedBody };
157
+ return markDroppedResponse(normalized, source);
158
+ } catch (error) {
159
+ if (source) throw markDiscardedResponseError(error, source);
160
+ throw error;
161
+ }
72
162
  }
73
163
  function composeRouteMiddlewares(middlewares, handler, inputSchema, outputSchema, inputValidationIndex, outputValidationIndex) {
74
164
  return async (initialContext, initialInput, request) => {
@@ -210,15 +300,7 @@ var HttpRouteBuilderImpl = class _HttpRouteBuilderImpl {
210
300
  const inputSchema = this.inputSchema;
211
301
  const outputSchema = this.outputSchema;
212
302
  const inputErrorMapper = this.inputErrorMapper;
213
- const runRoute = composeRouteMiddlewares(
214
- this.middlewares,
215
- handler,
216
- inputSchema,
217
- outputSchema,
218
- this.inputValidationIndex,
219
- this.outputValidationIndex
220
- );
221
- const wrappedHandler = async (ctx, req) => {
303
+ const invokeRoute = async (ctx, req) => {
222
304
  let normalizedInput;
223
305
  try {
224
306
  normalizedInput = inputSchema ? await buildInputFromRequest(method, req) : void 0;
@@ -231,14 +313,37 @@ var HttpRouteBuilderImpl = class _HttpRouteBuilderImpl {
231
313
  }
232
314
  let result;
233
315
  try {
234
- result = await runRoute(ctx, normalizedInput, req);
316
+ result = await composeRouteMiddlewares(
317
+ this.middlewares,
318
+ handler,
319
+ inputSchema,
320
+ outputSchema,
321
+ this.inputValidationIndex,
322
+ this.outputValidationIndex
323
+ )(ctx, normalizedInput, req);
235
324
  } catch (err) {
236
325
  if (err instanceof HttpRouteInputValidationError) {
237
326
  if (inputErrorMapper) return await inputErrorMapper(err.issues);
238
327
  return { status: 400, body: { error: err.message } };
239
328
  }
240
329
  if (err instanceof HttpRouteOutputValidationError) {
241
- return { status: 500, body: { error: `Output validation failed: ${err.message}` } };
330
+ const discardedResponse = getHttpRouteDiscardedResponse(err);
331
+ const failure = {
332
+ status: 500,
333
+ body: {
334
+ error: discardedResponse ? "Output validation failed" : `Output validation failed: ${err.message}`
335
+ }
336
+ };
337
+ if (discardedResponse) {
338
+ addDiscardedResponse(failure, discardedResponse);
339
+ Object.defineProperty(failure, outputValidationFailureSymbol, {
340
+ configurable: false,
341
+ enumerable: false,
342
+ value: true,
343
+ writable: false
344
+ });
345
+ }
346
+ return failure;
242
347
  }
243
348
  throw err;
244
349
  }
@@ -248,7 +353,7 @@ var HttpRouteBuilderImpl = class _HttpRouteBuilderImpl {
248
353
  method,
249
354
  path: routePath,
250
355
  isPublic,
251
- handler: wrappedHandler,
356
+ handler: invokeRoute,
252
357
  middlewares: [...this.middlewares],
253
358
  inputValidationIndex: this.inputValidationIndex,
254
359
  outputValidationIndex: this.outputValidationIndex,
@@ -1668,7 +1773,9 @@ async function validateWithSchema(schema, value, options) {
1668
1773
  }
1669
1774
  throw new GencowValidationError("Validation failed");
1670
1775
  }
1671
- throw new Error("procedure.query / procedure.mutation / procedure.internal only supports Standard Schema v1 validators");
1776
+ throw new Error(
1777
+ "procedure.query / procedure.mutation / procedure.internal only supports Standard Schema v1 validators"
1778
+ );
1672
1779
  }
1673
1780
  function composeMiddlewares(middlewares, handler, inputSchema, outputSchema, inputValidationIndex, outputValidationIndex) {
1674
1781
  return async (initialContext, initialInput) => {
@@ -1798,14 +1905,14 @@ var GencowProcedureBuilderImpl = class _GencowProcedureBuilderImpl {
1798
1905
  if (!this.procedureName) {
1799
1906
  throw new Error("Procedure name is required. Call .name(...) before .handler(...)");
1800
1907
  }
1801
- const run = composeMiddlewares(
1908
+ const run = (context, input) => composeMiddlewares(
1802
1909
  this.middlewares,
1803
1910
  handler,
1804
1911
  this.inputSchema,
1805
1912
  this.outputSchema,
1806
1913
  this.inputValidationIndex,
1807
1914
  this.outputValidationIndex
1808
- );
1915
+ )(context, input);
1809
1916
  return {
1810
1917
  kind: this.kind,
1811
1918
  name: this.procedureName,
@@ -6148,6 +6255,8 @@ export {
6148
6255
  eqRlsContextColumn,
6149
6256
  filterTenantRuntimeEnvVars,
6150
6257
  formatBytes,
6258
+ getHttpRouteDiscardedResponse,
6259
+ getHttpRouteDiscardedResponseCause,
6151
6260
  getJobAdmissionStatus,
6152
6261
  getMinuteBucketStart,
6153
6262
  getMsUntilNextMinute,
@@ -6167,6 +6276,7 @@ export {
6167
6276
  hasMapSessionToContext,
6168
6277
  inferTableNameFromColumn,
6169
6278
  isCronJobsBuilder,
6279
+ isHttpRouteOutputValidationFailure,
6170
6280
  isJobAdmissionError,
6171
6281
  isJobAdmissionKillSwitchActive,
6172
6282
  isReservedTenantRuntimeEnvKey,
package/docs/packaging.md CHANGED
@@ -40,11 +40,15 @@ CLI workspace source tree.
40
40
  Build from a complete repository checkout; workspace dependencies are required:
41
41
 
42
42
  ```sh
43
- pnpm --dir packages/cli run build
43
+ pnpm --dir packages/cli run build:with-deps
44
44
  ```
45
45
 
46
- The build script runs the prerequisite workspace builds declared in the CLI
47
- package scripts, then produces the package runtime artifacts. After the build,
46
+ `build:with-deps` builds the CLI's workspace dependencies (`@gencow/server`,
47
+ `@gencow/internal`, `@gencow/core`, and the rest of the CLI closure) once each in
48
+ topological order, then produces the package runtime artifacts. It selects packages
49
+ with the path selector `{.}...` rather than the name `gencow...`, because the root
50
+ package is also named `gencow` and a name selector would build the whole workspace. The plain `build`
51
+ script does not build dependencies; it expects their `dist/` output to exist. After the build,
48
52
  from `packages/cli`, create a local tarball without running package lifecycle
49
53
  hooks, inspect its file list, and run the package integrity check:
50
54
 
@@ -40,7 +40,7 @@ other work before handling arguments.
40
40
  selected client target. Use the command reference for documented output options;
41
41
  do not use subcommand `--help` as a syntax probe.
42
42
  - If the CLI package build reports a missing or incompatible artifact, rebuild
43
- from the repository root with `pnpm --dir packages/cli run build`, then rerun
43
+ from the repository root with `pnpm --dir packages/cli run build:with-deps`, then rerun
44
44
  `node scripts/pre-publish-check.mjs` from `packages/cli`.
45
45
  - For dependency resolution problems, run `pnpm install` at the workspace root
46
46
  and use the package manager declared by the project lockfile.
@@ -88,6 +88,32 @@ function formatPendingReasonLines(appRow) {
88
88
  };
89
89
  }
90
90
 
91
+ // A legacy-owner wake failure keeps the incumbent serving state and carries platform-authored guidance
92
+ // (retry condition, exact command). Render it instead of the generic "no deployment" text, which is wrong
93
+ // for an app that already has a legacy deployment.
94
+ function formatLegacyRedeployDetails(appRow) {
95
+ const pending = formatPendingReasonLines(appRow);
96
+ if (!pending || appRow?.runtimeRecoveryReason !== "legacy_runtime_preparation_required") return null;
97
+ const correlationId = appRow?.runtimeRecoveryCorrelationId;
98
+ return [
99
+ " Recovery: the app runtime is a legacy build the Platform cannot re-verify; the incumbent serving state was preserved.",
100
+ " Data/Deployment: unchanged; no data or serving state was changed.",
101
+ ` Code: ${appRow?.runtimeRecoveryErrorCode || "RUNTIME_CONVERGENCE_LEGACY_REDEPLOY_REQUIRED"}`,
102
+ pending.reason,
103
+ ...(correlationId ? [` Correlation: ${correlationId}`] : []),
104
+ pending.retryable,
105
+ pending.next,
106
+ ].join("\n");
107
+ }
108
+
109
+ // Platform marks an idle legacy-owner app (no wake failure recorded yet) with disposition.legacyRuntimeOwner.
110
+ const LEGACY_OWNER_REDEPLOY_DETAILS = [
111
+ " Recovery: the app runtime is a legacy build the Platform cannot re-verify; the incumbent serving state was preserved.",
112
+ " Data/Deployment: unchanged; no data or serving state was changed.",
113
+ " Retryable: no; deploy a new revision to move the app to the supported runtime.",
114
+ " Next command: gencow deploy --prod",
115
+ ].join("\n");
116
+
91
117
  export function formatRecoveryDispositionDetails(appRow) {
92
118
  // The accepted delete receipt remains the lifecycle authority while the app
93
119
  // is being removed. Do not replace it with unrelated recovery or support
@@ -95,7 +121,11 @@ export function formatRecoveryDispositionDetails(appRow) {
95
121
  if (appRow?.status === "deleting") return "";
96
122
  const disposition = appRow?.recoveryDisposition;
97
123
  if (disposition?.kind === "deploy_required") {
98
- return " Recovery: no deployment is available. Run `gencow deploy` from this project.\n Retryable: no";
124
+ return (
125
+ formatLegacyRedeployDetails(appRow) ??
126
+ (disposition.legacyRuntimeOwner === true ? LEGACY_OWNER_REDEPLOY_DETAILS : null) ??
127
+ " Recovery: no deployment is available. Run `gencow deploy` from this project.\n Retryable: no"
128
+ );
99
129
  }
100
130
  if (disposition?.kind === "owner_memory_action_required") {
101
131
  return " Recovery: inspect logs and memory usage, then deploy a fix or explicitly Resume.\n Retryable: no automatic retry";
@@ -3,6 +3,7 @@ import { resolve } from "path";
3
3
  import { BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, error, info, log, success } from "./output.mjs";
4
4
  import { platformFetch, requireCreds, rpcMutation, rpcQuery } from "./platform-client.mjs";
5
5
  import { readProjectMetadata } from "./project-context.mjs";
6
+ import { formatRestoreFailure } from "./backup-restore-diagnostic.mjs";
6
7
 
7
8
  export function resolveBackupAppId(restArgs, gencowJson = null) {
8
9
  let appId = null;
@@ -85,6 +86,92 @@ export function parseBackupListOptions(restArgs) {
85
86
  return options;
86
87
  }
87
88
 
89
+ // Public codes this renderer knows. Legacy BACKUP_CAPABILITY_NOT_READY rows are handled separately.
90
+ const BACKUP_TYPED_CODES = new Set([
91
+ "BACKUP_ACCESS_RECONCILING",
92
+ "BACKUP_RLS_RESTRICTIVE_POLICY",
93
+ "BACKUP_RLS_SCOPE_UNAVAILABLE",
94
+ "BACKUP_CAPABILITY_ATTESTATION_INVALID",
95
+ ]);
96
+
97
+ const BACKUP_PLATFORM_WAIT =
98
+ "No action is needed from you. Retry only after the backup access check reports ready; do not retry in a loop before then.";
99
+
100
+ // One entry per preflight reason the platform can store. A reason missing here is dropped from the rendered
101
+ // list, so it cannot reach the terminal under an unexplained code. Stored prose is never printed.
102
+ const BACKUP_REASON_PRESENTATION = {
103
+ attestation_metadata_invalid: { owner: "platform", nextAction: `Platform must correct the backup reader attestation. ${BACKUP_PLATFORM_WAIT}` },
104
+ role_attestation_missing: { owner: "platform", nextAction: `Platform must correct the backup reader attestation. ${BACKUP_PLATFORM_WAIT}` },
105
+ backup_identity_mismatch: { owner: "platform", nextAction: `Platform must correct the backup reader attestation. ${BACKUP_PLATFORM_WAIT}` },
106
+ role_has_broad_cluster_privilege: { owner: "platform", nextAction: `Platform must correct the backup reader attestation. ${BACKUP_PLATFORM_WAIT}` },
107
+ rls_bypass_not_approved: { owner: "platform", nextAction: `Platform must correct the backup reader attestation. ${BACKUP_PLATFORM_WAIT}` },
108
+ relation_select_missing: { owner: "platform", nextAction: `Platform is restoring backup access. ${BACKUP_PLATFORM_WAIT}` },
109
+ relation_ownership_forbidden: { owner: "platform", nextAction: `Platform is restoring backup access. ${BACKUP_PLATFORM_WAIT}` },
110
+ sequence_defaults_mutation_forbidden: { owner: "platform", nextAction: `Platform is restoring backup access. ${BACKUP_PLATFORM_WAIT}` },
111
+ complete_rls_dump_not_proven: { owner: "platform", nextAction: `Platform is restoring backup access. ${BACKUP_PLATFORM_WAIT}` },
112
+ rls_restrictive_policy_applies: {
113
+ owner: "app",
114
+ nextAction:
115
+ "Add a new migration that limits the restrictive RLS policy so it no longer applies to the Gencow backup reader (do not edit applied migrations), run `gencow deploy` from your app workspace, then retry the backup.",
116
+ },
117
+ rls_dump_scope_unbound: { owner: "platform", nextAction: `Platform must bind the backup scope for this app. ${BACKUP_PLATFORM_WAIT}` },
118
+ rls_scope_changed: {
119
+ owner: "platform",
120
+ nextAction:
121
+ "This is a transient race. A single retry after the current backup attempt finishes is expected to succeed. If it fails again, Platform is restoring access; do not retry in a loop, and cite the Reference below.",
122
+ },
123
+ };
124
+
125
+ // Stored shape written by the platform: "CODE[ [reasons=a,b]]: prose". Only the code and reason tokens are read.
126
+ const BACKUP_DIAGNOSIS_PATTERN = /^(BACKUP_[A-Z_]+)(?: \[reasons=([a-z_,]+)\])?: /u;
127
+
128
+ function parseBackupDiagnosis(backup) {
129
+ if (backup.status !== "failed" || typeof backup.errorMessage !== "string") return null;
130
+ const match = BACKUP_DIAGNOSIS_PATTERN.exec(backup.errorMessage);
131
+ if (!match) return null;
132
+ const code = match[1];
133
+ // Legacy rows stored before the typed codes existed keep their original fixed rendering.
134
+ if (code === "BACKUP_CAPABILITY_NOT_READY") return { code, legacy: true };
135
+ if (!BACKUP_TYPED_CODES.has(code)) return null;
136
+ const reasons = [
137
+ ...new Set(
138
+ (match[2] ?? "")
139
+ .split(",")
140
+ .filter((token) => Object.prototype.hasOwnProperty.call(BACKUP_REASON_PRESENTATION, token)),
141
+ ),
142
+ ];
143
+ return reasons.length > 0 ? { code, reasons } : null;
144
+ }
145
+
146
+ function renderBackupDiagnosis(diagnosis, backupId) {
147
+ if (diagnosis.legacy) {
148
+ return [
149
+ " BACKUP_CAPABILITY_NOT_READY: Backup preflight is not ready. The dump did not start; no data or serving state changed. Retry only after Platform confirms complete backup visibility and protected snapshot and authorization readiness for this app. Check the failed backup with `gencow backup list` from its app workspace.",
150
+ ];
151
+ }
152
+ const presentations = diagnosis.reasons.map((reason) => ({ reason, ...BACKUP_REASON_PRESENTATION[reason] }));
153
+ const appSteps = [...new Set(presentations.filter((p) => p.owner === "app").map((p) => p.nextAction))];
154
+ const platformReasons = presentations.filter((p) => p.owner === "platform").map((p) => p.reason);
155
+ const platformSteps = [...new Set(presentations.filter((p) => p.owner === "platform").map((p) => p.nextAction))];
156
+ const owners = [appSteps.length > 0 ? "App" : null, platformSteps.length > 0 ? "Platform" : null].filter(Boolean);
157
+ const lines = [
158
+ ` ${diagnosis.code}`,
159
+ ` Stage: preflight | State: unchanged (no data or serving state changed) | Owner: ${owners.join(" and ")}`,
160
+ ` Reasons: ${diagnosis.reasons.join(", ")}`,
161
+ ];
162
+ if (appSteps.length > 0 && platformSteps.length > 0) {
163
+ // Mixed refusal: the user acts on the app part only, and the Platform part is named as already in progress.
164
+ lines.push(` Next (you): ${appSteps.join(" ")}`);
165
+ lines.push(
166
+ ` Platform part: Platform is restoring the Platform-owned reasons (${platformReasons.join(", ")}); no action is needed from you for them. ${platformSteps.join(" ")}`,
167
+ );
168
+ } else {
169
+ lines.push(` Next: ${[...appSteps, ...platformSteps].join(" ")}`);
170
+ }
171
+ lines.push(` Reference: backup #${backupId}`);
172
+ return lines;
173
+ }
174
+
88
175
  function renderBackupHelp(logImpl = log, errorImpl = error, subCmd = null) {
89
176
  if (subCmd) errorImpl(`Unknown sub-command: ${subCmd}`);
90
177
  logImpl(`\n ${BOLD}Usage:${RESET}`);
@@ -98,6 +185,10 @@ function renderBackupHelp(logImpl = log, errorImpl = error, subCmd = null) {
98
185
  logImpl(` gencow backup download <id> Download backup file (plan-dependent)\n`);
99
186
  }
100
187
 
188
+ async function readErrorBody(response) {
189
+ return (await response.json?.().catch(() => ({}))) ?? {};
190
+ }
191
+
101
192
  export function buildRestoreConfirmPrompt(sourceLabel, { includeStorageRisk = false } = {}) {
102
193
  const storageRisk = includeStorageRisk
103
194
  ? `\n ${YELLOW}⚠ 백업 파일 복원은 DB만 교체하며 storage object/blob는 함께 이동하지 않습니다.${RESET}` +
@@ -201,16 +292,11 @@ export function createBackupCommand({
201
292
  logImpl(
202
293
  ` ${DIM}#${backup.id}${RESET} ${statusColor}${backup.status.padEnd(10)}${RESET} ${backup.type.padEnd(11)} ${formatBackupRetentionClass(backup).padEnd(14)} ${formatBackupSize(backup.fileSize).padStart(10)} ${formatBackupDate(backup.createdAt)} ${DIM}expires ${formatBackupExpiry(backup.expiresAt)}${RESET}${backup.note ? ` ${DIM}${backup.note}${RESET}` : ""}`,
203
294
  );
204
- // Recognize the stable producer code, but never print legacy/raw
205
- // errorMessage contents (which may contain SQL or credentials).
206
- if (
207
- backup.status === "failed" &&
208
- typeof backup.errorMessage === "string" &&
209
- backup.errorMessage.startsWith("BACKUP_CAPABILITY_NOT_READY:")
210
- ) {
211
- logImpl(
212
- " BACKUP_CAPABILITY_NOT_READY: Backup preflight is not ready. The dump did not start; no data or serving state changed. Retry only after Platform confirms complete backup visibility and protected snapshot and authorization readiness for this app. Check the failed backup with `gencow backup list` from its app workspace.",
213
- );
295
+ // Recognize the stable producer code, but never print raw errorMessage contents
296
+ // (which may contain SQL or credentials).
297
+ const diagnosis = parseBackupDiagnosis(backup);
298
+ if (diagnosis) {
299
+ for (const line of renderBackupDiagnosis(diagnosis, backup.id)) logImpl(line);
214
300
  }
215
301
  }
216
302
  }
@@ -267,9 +353,7 @@ export function createBackupCommand({
267
353
  logImpl(`\n${BOLD}Restoring from backup #${backupId}...${RESET}\n`);
268
354
  const response = await rpcMutationImpl(creds, "backup.restore", { appName: appId, backupId });
269
355
  if (!response.ok) {
270
- errorImpl(
271
- `Restore failed: ${(await response.json().catch(() => ({}))).error || response.statusText}`,
272
- );
356
+ errorImpl(formatRestoreFailure(await readErrorBody(response), response.statusText));
273
357
  return;
274
358
  }
275
359
  successImpl("Database restored. App restarted.");
@@ -306,9 +390,7 @@ export function createBackupCommand({
306
390
  body: form,
307
391
  });
308
392
  if (!response.ok) {
309
- errorImpl(
310
- `Restore failed: ${(await response.json?.().catch(() => ({}))).error || response.statusText}`,
311
- );
393
+ errorImpl(formatRestoreFailure(await readErrorBody(response), response.statusText));
312
394
  return;
313
395
  }
314
396
  successImpl("Database restored from file. App restarted.");
@@ -0,0 +1,185 @@
1
+ // Renders the typed diagnostics of a failed `backup restore` / `backup restore-file` response.
2
+ //
3
+ // The server body is untrusted. Every field is re-validated here, and each one is dropped on its own when it
4
+ // fails, so one bad value never hides the others. The headline prose is owned by this CLI, keyed by the public
5
+ // code, and the server's `error` text is never printed on the typed path. A body without a known code keeps the
6
+ // legacy `Restore failed: <error>` line, so old servers render exactly as before.
7
+
8
+ const KNOWN_RESTORE_CODES = {
9
+ BACKUP_RESTORE_ARCHIVE_UNSUPPORTED_OBJECTS: "This backup contains objects Gencow cannot restore safely.",
10
+ BACKUP_RESTORE_ARCHIVE_UNREADABLE: "This file is not a readable Gencow or PostgreSQL custom-format backup.",
11
+ BACKUP_RESTORE_EXECUTION_FAILED: "The restore did not finish. The app database may be partially restored.",
12
+ };
13
+
14
+ const CODE_PATTERN = /^BACKUP_RESTORE_[A-Z_]{3,80}$/u;
15
+ const STAGE_PATTERN = /^[a-z_]{3,40}$/u;
16
+ const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
17
+ const RETRY_CONDITION_MAX_LENGTH = 300;
18
+ const LEGACY_ERROR_MAX_LENGTH = 2000;
19
+ const NEXT_COMMAND_MAX_LENGTH = 160;
20
+ const UNSUPPORTED_OBJECT_SHOWN = 5;
21
+ // Items past this input cap are never inspected, so they are counted as omitted.
22
+ const UNSUPPORTED_OBJECT_INPUT_MAX = 20;
23
+ const OBJECT_TYPE_PATTERN = /^[A-Za-z][A-Za-z ]{0,40}$/u;
24
+ const OBJECT_NAME_PATTERN = /^[\p{L}\p{N}_ .$-]{1,128}$/u;
25
+ // The only next commands the server sends. Anything else (flags, other subcommands, separators) is dropped and
26
+ // replaced by the code default below, so the server never chooses an arbitrary command for the user.
27
+ const ALLOWED_NEXT_COMMANDS = new Set([
28
+ "gencow backup list",
29
+ "gencow backup create",
30
+ "gencow backup restore <backupId>",
31
+ "gencow backup restore-file <path>",
32
+ ]);
33
+ // Used when a known restore code arrives without a safe next command, so every failure shows a next step.
34
+ const DEFAULT_NEXT_COMMAND_BY_CODE = {
35
+ BACKUP_RESTORE_ARCHIVE_UNSUPPORTED_OBJECTS: "gencow backup create",
36
+ BACKUP_RESTORE_ARCHIVE_UNREADABLE: "gencow backup restore-file <path>",
37
+ BACKUP_RESTORE_EXECUTION_FAILED: "gencow backup list",
38
+ };
39
+ // ANSI CSI, OSC and two-character escapes, then any remaining C0/C1 control character.
40
+ const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\)|[@-Z\\-_])/gu;
41
+ const LINE_BREAK_PATTERN = /[\r\n\t]/gu;
42
+ // Bidi embedding/override/isolate controls and zero-width characters can reorder or hide text in a terminal.
43
+ const INVISIBLE_FORMAT_PATTERN = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/gu;
44
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/gu;
45
+ // Secret-shaped text is dropped as a whole field, never shown masked.
46
+ const SECRET_LIKE_PATTERNS = [/[A-Za-z0-9_-]{32,}/u, /\bBearer\s/iu, /\bpostgres(?:ql)?:\/\//iu];
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ // Line breaks and tabs become spaces so that word breaks survive; other control and invisible characters go.
53
+ function cleanPlainText(value) {
54
+ return value
55
+ .replace(ANSI_ESCAPE_PATTERN, "")
56
+ .replace(LINE_BREAK_PATTERN, " ")
57
+ .replace(INVISIBLE_FORMAT_PATTERN, "")
58
+ .replace(CONTROL_CHARACTER_PATTERN, "")
59
+ .trim();
60
+ }
61
+
62
+ // Oversized input is dropped rather than truncated, so a partial typed field is never shown as if complete.
63
+ function cleanText(value, maxLength) {
64
+ if (typeof value !== "string" || value.length > maxLength) return undefined;
65
+ const cleaned = cleanPlainText(value);
66
+ return cleaned.length > 0 ? cleaned : undefined;
67
+ }
68
+
69
+ // The legacy message is free text with no contract, so it is cut to a bounded prefix and marked, not dropped.
70
+ function cleanLegacyMessage(value) {
71
+ if (typeof value !== "string") return undefined;
72
+ const cleaned = cleanPlainText(value);
73
+ if (cleaned.length === 0) return undefined;
74
+ return cleaned.length > LEGACY_ERROR_MAX_LENGTH ? `${cleaned.slice(0, LEGACY_ERROR_MAX_LENGTH)}...` : cleaned;
75
+ }
76
+
77
+ function cleanRetryCondition(value) {
78
+ const cleaned = cleanText(value, RETRY_CONDITION_MAX_LENGTH);
79
+ if (cleaned === undefined || SECRET_LIKE_PATTERNS.some((pattern) => pattern.test(cleaned))) return undefined;
80
+ return cleaned;
81
+ }
82
+
83
+ function cleanNextCommand(value, safetyBackupId) {
84
+ // Exact match against the allowlist: the raw value must already be one of the known commands.
85
+ if (typeof value !== "string" || !ALLOWED_NEXT_COMMANDS.has(value)) return undefined;
86
+ // The safety-backup id is a validated integer, so the placeholder is filled with it. Nothing else is rewritten.
87
+ return safetyBackupId === undefined ? value : value.replace("<backupId>", String(safetyBackupId));
88
+ }
89
+
90
+ function readSafetyBackupId(body) {
91
+ const candidate = body.safetyBackupId ?? (isPlainObject(body.details) ? body.details.safetyBackupId : undefined);
92
+ return Number.isSafeInteger(candidate) && candidate > 0 ? candidate : undefined;
93
+ }
94
+
95
+ // Accepts the flat `unsupportedObjects` objects (restore-file) and the `details` strings (RPC restore).
96
+ function formatUnsupportedObject(item) {
97
+ if (typeof item === "string") {
98
+ const text = cleanText(item, 200);
99
+ const [type, ...rest] = (text ?? "").split(" ");
100
+ const name = rest.join(" ");
101
+ return text !== undefined && OBJECT_TYPE_PATTERN.test(type) && OBJECT_NAME_PATTERN.test(name) ? text : undefined;
102
+ }
103
+ if (!isPlainObject(item)) return undefined;
104
+ const type = cleanText(item.type, 41);
105
+ const schema = cleanText(item.schema, 128);
106
+ const name = cleanText(item.name, 128);
107
+ if (!OBJECT_TYPE_PATTERN.test(type ?? "") || !OBJECT_NAME_PATTERN.test(schema ?? "") || !OBJECT_NAME_PATTERN.test(name ?? "")) {
108
+ return undefined;
109
+ }
110
+ return `${type} ${schema}.${name}`;
111
+ }
112
+
113
+ function readUnsupportedObjects(body) {
114
+ const raw = Array.isArray(body.unsupportedObjects)
115
+ ? body.unsupportedObjects
116
+ : isPlainObject(body.details) && Array.isArray(body.details.unsupportedObjects)
117
+ ? body.details.unsupportedObjects
118
+ : [];
119
+ const valid = raw
120
+ .slice(0, UNSUPPORTED_OBJECT_INPUT_MAX)
121
+ .map(formatUnsupportedObject)
122
+ .filter((entry) => entry !== undefined);
123
+ return {
124
+ valid: valid.length,
125
+ omitted: raw.length - valid.length,
126
+ shown: valid.slice(0, UNSUPPORTED_OBJECT_SHOWN),
127
+ };
128
+ }
129
+
130
+ function stateLine(stateChanged) {
131
+ if (stateChanged === false) return "unchanged (no data or serving state changed)";
132
+ if (stateChanged === true) return "changed (data or serving state changed)";
133
+ // Anything else, including a missing or unrecognized value, is treated as unknown: never as unchanged.
134
+ return "unknown (the app database may be partially changed; do not assume it is intact)";
135
+ }
136
+
137
+ function ownerLine(responsibility) {
138
+ if (responsibility === "user") return "you";
139
+ if (responsibility === "platform") return "Platform";
140
+ return undefined;
141
+ }
142
+
143
+ function formatTypedRestoreFailure(body) {
144
+ const code = body.code;
145
+ const stage = typeof body.failedStage === "string" && STAGE_PATTERN.test(body.failedStage) ? body.failedStage : undefined;
146
+ const correlationId =
147
+ typeof body.correlationId === "string" && CORRELATION_ID_PATTERN.test(body.correlationId)
148
+ ? body.correlationId
149
+ : undefined;
150
+ const safetyBackupId = readSafetyBackupId(body);
151
+ const retryCondition = cleanRetryCondition(body.retryPrecondition);
152
+ const nextCommand = cleanNextCommand(body.nextCommand, safetyBackupId) ?? DEFAULT_NEXT_COMMAND_BY_CODE[code];
153
+ const owner = ownerLine(body.responsibility);
154
+ const objects = readUnsupportedObjects(body);
155
+
156
+ const lines = [`Restore failed: ${KNOWN_RESTORE_CODES[code]}`, `Code: ${code}`];
157
+ if (stage) lines.push(`Stage: ${stage}`);
158
+ lines.push(`State: ${stateLine(body.stateChanged)}`);
159
+ if (owner) lines.push(`Owner: ${owner}`);
160
+ lines.push(`Retryable: ${typeof body.retryable === "boolean" ? (body.retryable ? "yes" : "no") : "unknown"}`);
161
+ if (retryCondition) lines.push(`Retry condition: ${retryCondition}`);
162
+ if (objects.valid + objects.omitted > 0) {
163
+ const omittedLabel = objects.omitted > 0 ? `, ${objects.omitted} omitted as unreadable` : "";
164
+ lines.push(`Unsupported objects (${objects.valid} valid${omittedLabel}):`);
165
+ for (const entry of objects.shown) lines.push(` - ${entry}`);
166
+ const more = objects.valid - objects.shown.length;
167
+ if (more > 0) lines.push(` ...and ${more} more (see the backup file)`);
168
+ }
169
+ lines.push(`Next command: ${nextCommand}`);
170
+ if (correlationId) lines.push(`Correlation ID: ${correlationId}`);
171
+ return lines.join("\n");
172
+ }
173
+
174
+ /**
175
+ * Message for a failed restore response. `body` is the parsed JSON error body (or {}), and `statusText` is the
176
+ * HTTP status text used when the body carries no usable legacy message.
177
+ */
178
+ export function formatRestoreFailure(body, statusText) {
179
+ const safeBody = isPlainObject(body) ? body : {};
180
+ if (typeof safeBody.code === "string" && CODE_PATTERN.test(safeBody.code) && KNOWN_RESTORE_CODES[safeBody.code]) {
181
+ return formatTypedRestoreFailure(safeBody);
182
+ }
183
+ const legacy = cleanLegacyMessage(safeBody.error) ?? cleanText(statusText, 200);
184
+ return `Restore failed: ${legacy ?? "unknown error"}`;
185
+ }