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 +129 -19
- package/docs/packaging.md +7 -3
- package/docs/troubleshooting.md +1 -1
- package/lib/app-command.mjs +31 -1
- package/lib/backup-command.mjs +98 -16
- package/lib/backup-restore-diagnostic.mjs +185 -0
- package/lib/deploy-command.mjs +10 -4
- package/lib/deploy-failure-diagnostic.mjs +6 -4
- package/lib/deploy-logs-diagnostic.mjs +67 -0
- package/package.json +5 -3
- package/runtime/server.mjs +337 -317
- package/scripts/pre-publish-check.mjs +2 -2
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
package/lib/app-command.mjs
CHANGED
|
@@ -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
|
|
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";
|
package/lib/backup-command.mjs
CHANGED
|
@@ -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
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|