@tailor-platform/sdk 2.0.1 → 2.2.0
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/CHANGELOG.md +117 -0
- package/{dist/cli/index.mjs → bin/tailor.mjs} +1 -1
- package/dist/{application-CM6hKnSK.mjs → application-BpLeawBg.mjs} +126 -28
- package/dist/application-BpLeawBg.mjs.map +1 -0
- package/dist/application-BxLLiLsr.mjs +3 -0
- package/dist/cli/commands/deploy/tailordb/index.d.mts +2 -1
- package/dist/cli/commands/deploy/types.d.mts +2 -1
- package/dist/cli/commands/tailordb/migrate/diff-calculator.d.mts +11 -4
- package/dist/cli/commands/tailordb/migrate/file-state.d.mts +1 -0
- package/dist/cli/commands/tailordb/migrate/schema-checks.d.mts +2 -2
- package/dist/cli/commands/tailordb/migrate/snapshot-types.d.mts +8 -1
- package/dist/cli/commands/tailordb/migrate/snapshot.d.mts +5 -3
- package/dist/cli/lib.d.mts +4 -4
- package/dist/cli/lib.mjs +3 -482
- package/dist/cli/lib.mjs.map +1 -1
- package/dist/cli/main.mjs +997 -202
- package/dist/cli/main.mjs.map +1 -1
- package/dist/completion/zsh-worker.zsh +145 -2
- package/dist/configure/config/types.d.mts +12 -1
- package/dist/configure/index.d.mts +3 -3
- package/dist/configure/services/index.d.mts +3 -2
- package/dist/configure/services/resolver/index.d.mts +1 -1
- package/dist/configure/services/resolver/resolver.d.mts +1 -1
- package/dist/configure/services/tailordb/index.d.mts +2 -1
- package/dist/configure/services/tailordb/schema.d.mts +46 -46
- package/dist/configure/services/tailordb/types.d.mts +30 -4
- package/dist/{crashreport-B5LqCTfF.mjs → crashreport-CIIfLnoO.mjs} +2 -2
- package/dist/{crashreport-B5LqCTfF.mjs.map → crashreport-CIIfLnoO.mjs.map} +1 -1
- package/dist/{crashreport-eI7ty7ok.mjs → crashreport-CINZ5dVN.mjs} +1 -1
- package/dist/errors-CWj21238.mjs +75 -0
- package/dist/errors-CWj21238.mjs.map +1 -0
- package/dist/{globals-TfAVItuK.mjs → globals-B2nlxBrz.mjs} +2 -10
- package/dist/globals-B2nlxBrz.mjs.map +1 -0
- package/dist/kysely/index.d.mts +52 -5
- package/dist/kysely/index.mjs +7 -2
- package/dist/kysely/index.mjs.map +1 -1
- package/dist/{logger-CXQq9YIp.mjs → logger-DSdExfor.mjs} +83 -42
- package/dist/logger-DSdExfor.mjs.map +1 -0
- package/dist/plugin/builtin/seed/index.mjs +1 -1
- package/dist/{register-ts-hook-LYV7zH-e.mjs → register-ts-hook-CTth1eqj.mjs} +1587 -272
- package/dist/register-ts-hook-CTth1eqj.mjs.map +1 -0
- package/dist/runtime/globals.d.mts +0 -7
- package/dist/runtime/workflow.d.mts +2 -7
- package/dist/schema--xYWRGfe.mjs.map +1 -1
- package/dist/seed/index.d.mts +52 -3
- package/dist/seed/index.mjs +200 -44
- package/dist/seed/index.mjs.map +1 -1
- package/dist/{seed-B-yckgQY.mjs → seed-xqM9WIHP.mjs} +8 -8
- package/dist/seed-xqM9WIHP.mjs.map +1 -0
- package/dist/{service-B5WynBHQ.mjs → service-CCwl3Avt.mjs} +3 -3
- package/dist/{service-B5WynBHQ.mjs.map → service-CCwl3Avt.mjs.map} +1 -1
- package/dist/service-_XmjSEGr.mjs +3 -0
- package/dist/{service-k1xy_A5G.mjs → service-goqlJJgA.mjs} +2 -2
- package/dist/{service-k1xy_A5G.mjs.map → service-goqlJJgA.mjs.map} +1 -1
- package/dist/{service_pb-D5--My_M.mjs → service_pb-BEWj1pOV.mjs} +2 -2
- package/dist/{service_pb-D5--My_M.mjs.map → service_pb-BEWj1pOV.mjs.map} +1 -1
- package/dist/{service_pb-i8CmnQP4.mjs → service_pb-BjlhvK9E.mjs} +1 -1
- package/dist/utils/test/index.d.mts +9 -3
- package/dist/utils/test/index.mjs +49 -25
- package/dist/utils/test/index.mjs.map +1 -1
- package/dist/vitest/environment.mjs +1 -1
- package/dist/vitest/index.d.mts +2 -1
- package/dist/vitest/index.mjs +84 -2
- package/dist/vitest/index.mjs.map +1 -1
- package/dist/vitest/mocks/file.d.mts +1 -1
- package/dist/vitest/pglite-kysely.d.mts +43 -0
- package/dist/vitest/setup.mjs +1 -1
- package/dist/workflow-Bamae_Yc.mjs.map +1 -1
- package/dist/{workspace_resource_pb-DXnO2AT-.mjs → workspace_resource_pb-HNIL_Vfj.mjs} +10 -2
- package/dist/{workspace_resource_pb-DXnO2AT-.mjs.map → workspace_resource_pb-HNIL_Vfj.mjs.map} +1 -1
- package/docs/cli/tailordb.md +87 -22
- package/docs/cli-reference.md +13 -11
- package/docs/configuration.md +4 -1
- package/docs/migration/v2.md +1 -1
- package/docs/plugin/custom.md +48 -48
- package/docs/plugin/index.md +24 -24
- package/docs/services/auth.md +10 -10
- package/docs/services/executor.md +1 -1
- package/docs/services/resolver.md +34 -2
- package/docs/services/tailordb-migration.md +375 -76
- package/docs/services/tailordb.md +28 -28
- package/docs/testing.md +3 -1
- package/package.json +13 -8
- package/dist/application-CM6hKnSK.mjs.map +0 -1
- package/dist/application-CXNaUNhv.mjs +0 -3
- package/dist/errors-D7583Zz7.mjs +0 -58
- package/dist/errors-D7583Zz7.mjs.map +0 -1
- package/dist/globals-TfAVItuK.mjs.map +0 -1
- package/dist/logger-CXQq9YIp.mjs.map +0 -1
- package/dist/register-ts-hook-LYV7zH-e.mjs.map +0 -1
- package/dist/seed-B-yckgQY.mjs.map +0 -1
- package/dist/service-M7OYziQP.mjs +0 -3
package/dist/cli/main.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { r as parseInputFields } from "../schema--xYWRGfe.mjs";
|
|
3
|
-
import { At as PATScope, Ct as AuthInvokerSchema, E as CustomDomainStatus, W as FunctionExecution_Type } from "../workspace_resource_pb-
|
|
3
|
+
import { At as PATScope, Ct as AuthInvokerSchema, E as CustomDomainStatus, Ft as CloneOperationStatus, W as FunctionExecution_Type } from "../workspace_resource_pb-HNIL_Vfj.mjs";
|
|
4
4
|
import { t as assertDefined } from "../assert-DBxo8jPo.mjs";
|
|
5
|
-
import { n as logger, r as styles } from "../logger-
|
|
6
|
-
import { $
|
|
7
|
-
import { C as
|
|
8
|
-
import { i as ExecutorSchema, r as stripExecutorTriggerArgs } from "../service-
|
|
5
|
+
import { n as logger, r as styles } from "../logger-DSdExfor.mjs";
|
|
6
|
+
import { $ as treeCommand, $n as formatDiffSummary, An as MIGRATION_NUMBER_PATTERN, Ar as removeLegacyUserAlias, B as MIGRATION_REVIEW_REQUIRED_MARKER, Br as isVerbose, Bt as getCommand$6, C as healthCommand, Cn as captureFileState, Cr as loadConsoleBaseUrl, Ct as listCommand$9, Dr as loadWorkspaceId, E as resumeCommand, En as DIFF_FILE_NAME, Er as loadStoredUserTokens, F as chunkSeedData, Fn as compareLocalTypesWithSnapshot, Fr as defineAppCommand, Ft as jobsCommand, G as showCommand, Gn as loadSnapshot, Gr as toPageDirection, Gt as functionExecutionStatusToString, H as deployCommand$1, Hn as getMigrationFiles, Hr as pagedLogArgs, Ht as executionsCommand, I as bundleSeedScript, In as compareSnapshots, Ir as commonArgs, Jn as MIGRATION_HISTORY_LABEL_KEY, K as extractOwnedNamespaces, Kn as normalizeSchemaSnapshot, Kr as workspaceArgs, Kt as getCommand$1, Ln as createSnapshotFromLocalTypes, Lr as configArg, M as loadSeedContext, Mn as UnsupportedMigrationFileVersionError, Mr as resolveUserTokenKey, N as loadApplicationNamespaces, Nn as assertMigrationNumberExists, Nr as saveUserTokens, O as listCommand$14, On as MIGRATE_FILE_NAME, Or as platformConfigFromProfile, Ot as webhookCommand, Pn as assertValidMigrationFiles, Pr as writePlatformConfig, Qn as sanitizeMigrationLabel, Qt as listCommand$17, R as generateCommand$1, Rn as formatSchemaDrifts, Rr as confirmationArgs, Rt as startCommand, Sn as protoGqlPermission, Sr as loadConfigPath, St as generate, Tn as DB_TYPES_FILE_NAME, Tr as loadPlatformClientConfig, Tt as getCommand$2, Ur as paginationArgs, V as generateSchemaFile, Vn as getMigrationFilePath, Vr as multiConfigArg, Wn as loadDiff, Wr as resolveMachineUserInputSource, X as updateCommand$3, Xn as createMigrationHistoryId, Xt as deployMigrationTestBaseline, Y as removeCommand$1, Yn as MIGRATION_LABEL_KEY, Yt as deployFromCLI, Zn as handleOptionalToRequiredError, Zt as deployMigrationTestTarget, _n as verifyRemoteSchema, _r as deleteUserTokens, a as queryCommand, an as ensureConfigId, ar as sdkAppIdLabelKey, bn as compareSnapshotWithRemote, br as hasUserTokenEntry, bt as listCommand$10, c as removeCommand, cn as executeScript, cr as writeMetadataLabels, ct as getCommand$4, dn as bundleMigrationScript, dr as formatMigrationNumber, en as createCommand$4, er as formatMigrationDiff, et as listCommand$13, f as inviteCommand, fn as checkMigrationDiffs, fr as parseMigrationNumberArg, ft as createCommand$3, g as getCommand$7, gn as toTailorDBDeployInput, gr as loadConfig, gt as getCommand$3, hn as logRemoteDriftGuidance, hr as assertWritable, in as workspaceNameSchema, ir as resourceTrn, it as updateCommand$2, j as truncateCommand, jn as SCHEMA_FILE_NAME, jr as resolveConfigUser, jt as listCommand$8, kr as readPlatformConfig, kt as triggerCommand, lr as PluginManager, m as restoreCommand, mn as formatRemoteVerificationResults, mr as apiCall, mt as listCommand$11, nr as getNamespacesWithMigrations, nt as getCommand$5, o as updateCommand$4, on as getMigrationMachineUser, or as sdkNameLabelKey, ot as listCommand$12, pn as fetchRemoteSchemaSnapshot, pr as prompt, q as logBetaWarning, qn as reconstructSnapshotFromMigrations, r as serializeError, rn as formatKeyValueTable, rr as selectTargetNamespace, sn as updateMigrationLabel, sr as sdkVersionLabelKey, t as registerTsHook, tn as createValidatedWorkspaceWithClient, tr as hasChanges, u as listCommand$16, un as formatMigrationScriptCommand, ut as deleteCommand$4, v as deleteCommand$5, vn as fetchRemoteMigrationNumber, vr as fetchLatestToken, w as waitCommand, wn as captureMigrationFileState, wr as loadMachineUserName, x as listCommand$15, xn as generateAllTypeManifestsFromSnapshot, xr as loadAccessToken, yn as fetchRemoteMigrationState, yr as hasAnyUserTokenEntry, yt as tokenCommand$1, z as scriptCommand, zn as getLatestMigrationNumber, zr as deploymentArgs } from "../register-ts-hook-CTth1eqj.mjs";
|
|
7
|
+
import { A as getDistDir, B as fetchUserInfo, C as composeFunctionTreeshakeOptions, E as createBundleLog, F as fetchAll, G as initOAuth2Client, H as getOAuth2ClientId, I as fetchAllTolerant, K as initOperatorClient, N as closeConnectionPool, P as defaultPlatformBaseUrl, R as fetchPaged, S as platformBundleDefinePlugin, T as resolveBundleLogLevel, U as getOrNull, W as getPlatformBaseUrl, a as resolveInlineSourcemap, b as createTsconfigPathsPlugin, c as WorkflowJobSchema, f as INVOKER_EXPR, g as assertUniqueLocalTailorDBTypeNames, h as buildResolverPermissionAndInputCheckExpr, l as resolveResolverDefaultPermissionForFile, q as isDefaultPlatform, u as ResolverSchema, w as createLogLevelTreeshakeOptions, x as resolveTSConfigWithFallback, y as createGeneratedEntryResolverPlugin, z as fetchPlatformMachineUserToken } from "../application-BpLeawBg.mjs";
|
|
8
|
+
import { h as loadFilesWithIgnores, i as ExecutorSchema, r as stripExecutorTriggerArgs } from "../service-goqlJJgA.mjs";
|
|
9
9
|
import { t as multiline } from "../multiline-sfHpTZZK.mjs";
|
|
10
10
|
import { t as readPackageJson } from "../package-json-8b0O9TlX.mjs";
|
|
11
11
|
import { t as userAgent } from "../user-agent-Bgsszb5I.mjs";
|
|
12
|
-
import {
|
|
13
|
-
import { t as OperatorService } from "../service_pb-
|
|
14
|
-
import { a as JSON_FOOTER_MARKER, i as CRASH_LOG_EXTENSION, o as parseCrashReportConfig, r as sendCrashReport, t as initCrashReporting } from "../crashreport-
|
|
12
|
+
import { i as isCLIError, r as formatNextAction, t as createCLIError } from "../errors-CWj21238.mjs";
|
|
13
|
+
import { t as OperatorService } from "../service_pb-BEWj1pOV.mjs";
|
|
14
|
+
import { a as JSON_FOOTER_MARKER, i as CRASH_LOG_EXTENSION, o as parseCrashReportConfig, r as sendCrashReport, t as initCrashReporting } from "../crashreport-CIIfLnoO.mjs";
|
|
15
15
|
import * as path from "pathe";
|
|
16
16
|
import { dirname, resolve } from "pathe";
|
|
17
17
|
import { resolvePackageJSON } from "pkg-types";
|
|
@@ -27,7 +27,7 @@ import { pathToFileURL } from "node:url";
|
|
|
27
27
|
import { generateCodeVerifier } from "@badgateway/oauth2-client";
|
|
28
28
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
29
29
|
import * as crypto from "node:crypto";
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
30
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
31
31
|
import * as http from "node:http";
|
|
32
32
|
import open from "open";
|
|
33
33
|
import * as rolldown from "rolldown";
|
|
@@ -1498,7 +1498,7 @@ function composeExecutionErrorString(error) {
|
|
|
1498
1498
|
* @returns Formatted fallback string for display
|
|
1499
1499
|
*/
|
|
1500
1500
|
function formatExecutionErrorFallback(error) {
|
|
1501
|
-
const [headerLine, ...frameLines] = composeExecutionErrorString(error).split("\n");
|
|
1501
|
+
const [headerLine = "", ...frameLines] = composeExecutionErrorString(error).split("\n");
|
|
1502
1502
|
return [` ${styles.error(headerLine)}`, ...frameLines.map((line) => ` ${styles.dim(line)}`)].join("\n");
|
|
1503
1503
|
}
|
|
1504
1504
|
/**
|
|
@@ -1694,7 +1694,14 @@ async function bundleForTestRun(options) {
|
|
|
1694
1694
|
const baseName = `test-run--${detected.name}`;
|
|
1695
1695
|
const scriptName = `${baseName}.js`;
|
|
1696
1696
|
const entryPath = path.join(outputDir, `${baseName}.entry.js`);
|
|
1697
|
-
const entryContent = generateEntry(
|
|
1697
|
+
const entryContent = generateEntry({
|
|
1698
|
+
detected,
|
|
1699
|
+
sourceFile,
|
|
1700
|
+
env,
|
|
1701
|
+
machineUser,
|
|
1702
|
+
workspaceId,
|
|
1703
|
+
defaultPermission: options.defaultPermission
|
|
1704
|
+
});
|
|
1698
1705
|
fs.writeFileSync(entryPath, entryContent);
|
|
1699
1706
|
const tsconfig = await resolveTSConfigWithFallback(baseDir);
|
|
1700
1707
|
const bundleLog = createBundleLog({ tsconfig });
|
|
@@ -1725,14 +1732,11 @@ async function bundleForTestRun(options) {
|
|
|
1725
1732
|
}
|
|
1726
1733
|
/**
|
|
1727
1734
|
* Generate entry file content based on the detected function type.
|
|
1728
|
-
* @param
|
|
1729
|
-
* @param sourceFile - Absolute path to the source file
|
|
1730
|
-
* @param env - Environment variables for workflow job bundles
|
|
1731
|
-
* @param machineUser - Resolved machine user info
|
|
1732
|
-
* @param workspaceId - Workspace ID
|
|
1735
|
+
* @param options - Detected function info and the context embedded into the entry
|
|
1733
1736
|
* @returns Entry file content string
|
|
1734
1737
|
*/
|
|
1735
|
-
function generateEntry(
|
|
1738
|
+
function generateEntry(options) {
|
|
1739
|
+
const { detected, sourceFile, env, machineUser, workspaceId, defaultPermission } = options;
|
|
1736
1740
|
const absoluteSourcePath = path.resolve(sourceFile);
|
|
1737
1741
|
switch (detected.type) {
|
|
1738
1742
|
case "plain":
|
|
@@ -1745,7 +1749,10 @@ function generateEntry(detected, sourceFile, env, machineUser, workspaceId) {
|
|
|
1745
1749
|
`;
|
|
1746
1750
|
case "resolver": {
|
|
1747
1751
|
const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId);
|
|
1748
|
-
const guardAndInputCheckExpr = buildResolverPermissionAndInputCheckExpr(
|
|
1752
|
+
const guardAndInputCheckExpr = buildResolverPermissionAndInputCheckExpr({
|
|
1753
|
+
permission: detected.permission,
|
|
1754
|
+
defaultPermission
|
|
1755
|
+
});
|
|
1749
1756
|
return multiline`
|
|
1750
1757
|
import _internalResolver from "${absoluteSourcePath}";
|
|
1751
1758
|
import { t } from "@tailor-platform/sdk";
|
|
@@ -2027,10 +2034,16 @@ When a \`.js\` file is provided, detection and bundling are skipped and the file
|
|
|
2027
2034
|
args.arg = void 0;
|
|
2028
2035
|
}
|
|
2029
2036
|
logger.info("Bundling...");
|
|
2037
|
+
const baseDir = path.dirname(config.path);
|
|
2030
2038
|
({bundledCode, scriptName} = await bundleForTestRun({
|
|
2031
2039
|
detected,
|
|
2032
2040
|
sourceFile: filePath,
|
|
2033
|
-
baseDir
|
|
2041
|
+
baseDir,
|
|
2042
|
+
defaultPermission: detected.type === "resolver" ? resolveResolverDefaultPermissionForFile({
|
|
2043
|
+
config: config.resolver,
|
|
2044
|
+
filePath,
|
|
2045
|
+
baseDir
|
|
2046
|
+
}) : void 0,
|
|
2034
2047
|
env: config.env ?? {},
|
|
2035
2048
|
inlineSourcemap: config.inlineSourcemap,
|
|
2036
2049
|
logLevel: config.logLevel,
|
|
@@ -5412,164 +5425,219 @@ const staticwebsiteCommand = defineCommand({
|
|
|
5412
5425
|
});
|
|
5413
5426
|
|
|
5414
5427
|
//#endregion
|
|
5415
|
-
//#region src/cli/commands/tailordb/migrate/
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5428
|
+
//#region src/cli/commands/tailordb/migrate/rebaseline.ts
|
|
5429
|
+
const MIGRATION_ARTIFACT_NAMES = /* @__PURE__ */ new Set([
|
|
5430
|
+
SCHEMA_FILE_NAME,
|
|
5431
|
+
DIFF_FILE_NAME,
|
|
5432
|
+
MIGRATE_FILE_NAME,
|
|
5433
|
+
DB_TYPES_FILE_NAME
|
|
5434
|
+
]);
|
|
5435
|
+
async function activateBaseline(migrationsDir, snapshot, namespace, rebaseline) {
|
|
5436
|
+
const resolvedMigrationsDir = await fsPromises.realpath(migrationsDir);
|
|
5437
|
+
const parentDir = path.dirname(resolvedMigrationsDir);
|
|
5438
|
+
const baseName = path.basename(resolvedMigrationsDir);
|
|
5439
|
+
const stagingDir = await fsPromises.mkdtemp(path.join(parentDir, `.${baseName}-rebaseline-`));
|
|
5440
|
+
const backupDir = `${stagingDir}-previous`;
|
|
5441
|
+
let movedLiveDirectory = false;
|
|
5442
|
+
let activatedStagingDirectory = false;
|
|
5443
|
+
try {
|
|
5444
|
+
await fsPromises.cp(resolvedMigrationsDir, stagingDir, { recursive: true });
|
|
5445
|
+
for (const entry of await fsPromises.readdir(stagingDir, { withFileTypes: true })) {
|
|
5446
|
+
if (!entry.isDirectory() || !MIGRATION_NUMBER_PATTERN.test(entry.name)) continue;
|
|
5447
|
+
const entryPath = path.join(stagingDir, entry.name);
|
|
5448
|
+
if (!(await fsPromises.readdir(entryPath)).some((childName) => MIGRATION_ARTIFACT_NAMES.has(childName))) continue;
|
|
5449
|
+
await fsPromises.rm(entryPath, {
|
|
5450
|
+
recursive: true,
|
|
5451
|
+
force: true
|
|
5452
|
+
});
|
|
5453
|
+
}
|
|
5454
|
+
loadSnapshot((await generateSchemaFile({
|
|
5455
|
+
...snapshot,
|
|
5456
|
+
namespace,
|
|
5457
|
+
version: 2,
|
|
5458
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5459
|
+
rebaseline
|
|
5460
|
+
}, stagingDir, 0)).filePath);
|
|
5461
|
+
await fsPromises.rename(resolvedMigrationsDir, backupDir);
|
|
5462
|
+
movedLiveDirectory = true;
|
|
5463
|
+
try {
|
|
5464
|
+
await fsPromises.rename(stagingDir, resolvedMigrationsDir);
|
|
5465
|
+
activatedStagingDirectory = true;
|
|
5466
|
+
} catch (error) {
|
|
5467
|
+
await fsPromises.rename(backupDir, resolvedMigrationsDir);
|
|
5468
|
+
movedLiveDirectory = false;
|
|
5469
|
+
throw error;
|
|
5470
|
+
}
|
|
5471
|
+
try {
|
|
5472
|
+
await fsPromises.rm(backupDir, {
|
|
5473
|
+
recursive: true,
|
|
5474
|
+
force: true
|
|
5475
|
+
});
|
|
5476
|
+
} catch (error) {
|
|
5477
|
+
logger.warn(`The previous migration directory remains at ${backupDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5478
|
+
}
|
|
5479
|
+
movedLiveDirectory = false;
|
|
5480
|
+
} finally {
|
|
5481
|
+
if (fs.existsSync(stagingDir)) await fsPromises.rm(stagingDir, {
|
|
5482
|
+
recursive: true,
|
|
5483
|
+
force: true
|
|
5484
|
+
});
|
|
5485
|
+
if (!activatedStagingDirectory && movedLiveDirectory && fs.existsSync(backupDir)) await fsPromises.rename(backupDir, resolvedMigrationsDir);
|
|
5486
|
+
}
|
|
5459
5487
|
}
|
|
5460
|
-
|
|
5461
|
-
* Add a migrate.ts template to an existing migration directory.
|
|
5462
|
-
* @param {ScriptOptions} options - Command options
|
|
5463
|
-
*/
|
|
5464
|
-
async function script(options) {
|
|
5488
|
+
async function rebaseline(options) {
|
|
5465
5489
|
logBetaWarning("tailordb migration");
|
|
5466
|
-
const
|
|
5467
|
-
|
|
5468
|
-
const
|
|
5469
|
-
const
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5490
|
+
const { config, plugins } = await loadConfig(options.configPath);
|
|
5491
|
+
const configDir = path.dirname(config.path);
|
|
5492
|
+
const target = selectTargetNamespace(getNamespacesWithMigrations(config, configDir), options.namespace);
|
|
5493
|
+
const generateCommand = formatNextAction({
|
|
5494
|
+
command: "tailor",
|
|
5495
|
+
args: [
|
|
5496
|
+
"tailordb",
|
|
5497
|
+
"migration",
|
|
5498
|
+
"generate",
|
|
5499
|
+
"--config",
|
|
5500
|
+
config.path
|
|
5501
|
+
]
|
|
5502
|
+
});
|
|
5503
|
+
assertValidMigrationFiles(target.migrationsDir, target.namespace);
|
|
5504
|
+
const latestSnapshot = reconstructSnapshotFromMigrations(target.migrationsDir);
|
|
5505
|
+
if (!latestSnapshot) throw new Error(`No migration history found for namespace "${target.namespace}". Run ${generateCommand} first.`);
|
|
5506
|
+
const latestMigration = getLatestMigrationNumber(target.migrationsDir);
|
|
5507
|
+
const currentHistoryId = reconstructSnapshotFromMigrations(target.migrationsDir, 0)?.rebaseline?.historyId ?? null;
|
|
5508
|
+
const initialFileState = captureMigrationFileState([target])[target.namespace];
|
|
5509
|
+
const pluginManager = plugins.length > 0 ? new PluginManager(plugins) : void 0;
|
|
5510
|
+
const { defineApplication } = await import("../application-BxLLiLsr.mjs");
|
|
5511
|
+
const application = defineApplication({
|
|
5512
|
+
config,
|
|
5513
|
+
pluginManager
|
|
5514
|
+
});
|
|
5515
|
+
for (const service of application.tailorDBServices) {
|
|
5516
|
+
await service.loadTypes();
|
|
5517
|
+
await service.processNamespacePlugins();
|
|
5485
5518
|
}
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5519
|
+
const targetService = application.tailorDBServices.find((service) => service.namespace === target.namespace);
|
|
5520
|
+
if (!targetService) throw new Error(`No TailorDB service found for namespace "${target.namespace}"`);
|
|
5521
|
+
const assertLocalTypesReady = () => {
|
|
5522
|
+
const currentSnapshot = createSnapshotFromLocalTypes(targetService.types, target.namespace);
|
|
5523
|
+
const localDiff = compareLocalTypesWithSnapshot(latestSnapshot, currentSnapshot.types, target.namespace);
|
|
5524
|
+
if (hasChanges(localDiff)) {
|
|
5525
|
+
logger.error(`Migration history does not reproduce the current local schema for namespace ${styles.bold(target.namespace)}:`);
|
|
5526
|
+
logger.log(formatMigrationDiff(localDiff));
|
|
5527
|
+
throw new Error(`Refusing to re-baseline: the migration history must reproduce the current local schema. Run ${generateCommand} first.`);
|
|
5528
|
+
}
|
|
5529
|
+
};
|
|
5530
|
+
const localSourceFiles = () => [config.path, ...loadFilesWithIgnores(targetService.config, configDir)];
|
|
5531
|
+
assertLocalTypesReady();
|
|
5532
|
+
const initialLocalFileState = captureFileState(localSourceFiles());
|
|
5533
|
+
const client = await initOperatorClient(await loadAccessToken({ profile: options.profile }));
|
|
5534
|
+
const workspaceId = await loadWorkspaceId({
|
|
5535
|
+
workspaceId: options.workspaceId,
|
|
5536
|
+
profile: options.profile
|
|
5537
|
+
});
|
|
5538
|
+
const remoteContextArgs = [
|
|
5539
|
+
"--config",
|
|
5540
|
+
config.path,
|
|
5541
|
+
"--workspace-id",
|
|
5542
|
+
workspaceId,
|
|
5543
|
+
...options.profile ? ["--profile", options.profile] : []
|
|
5544
|
+
];
|
|
5545
|
+
const setBaselineCommand = formatNextAction({
|
|
5546
|
+
command: "tailor",
|
|
5547
|
+
args: [
|
|
5548
|
+
"tailordb",
|
|
5549
|
+
"migration",
|
|
5550
|
+
"set",
|
|
5551
|
+
"0",
|
|
5552
|
+
"--namespace",
|
|
5553
|
+
target.namespace,
|
|
5554
|
+
...remoteContextArgs
|
|
5555
|
+
]
|
|
5556
|
+
});
|
|
5557
|
+
const deployCommand = formatNextAction({
|
|
5558
|
+
command: "tailor",
|
|
5559
|
+
args: ["deploy", ...remoteContextArgs]
|
|
5560
|
+
});
|
|
5561
|
+
const assertConnectedWorkspaceReady = async (expectedHistoryId, phase) => {
|
|
5562
|
+
const remoteState = await fetchRemoteMigrationState(client, resourceTrn(workspaceId, "tailordb", target.namespace));
|
|
5563
|
+
const remoteMigration = remoteState.number;
|
|
5564
|
+
if (remoteMigration !== latestMigration) {
|
|
5565
|
+
const actual = remoteMigration === null ? "<unset>" : formatMigrationNumber(remoteMigration);
|
|
5566
|
+
throw new Error(`The connected workspace must be at the latest migration ${formatMigrationNumber(latestMigration)} before re-baselining; current checkpoint is ${actual}.`);
|
|
5567
|
+
}
|
|
5568
|
+
if (remoteState.historyIdInvalid) throw new Error("Refusing to re-baseline: the connected workspace has an invalid migration history marker.");
|
|
5569
|
+
if (remoteState.historyId !== expectedHistoryId) throw new Error(phase === "confirmation" ? "The connected workspace migration history changed while waiting for confirmation. Run the command again." : "Refusing to re-baseline: the connected workspace does not match the local migration history.");
|
|
5570
|
+
const remoteResults = await verifyRemoteSchema(client, workspaceId, [target], config, [toTailorDBDeployInput(targetService)]);
|
|
5571
|
+
const remoteResult = remoteResults[0];
|
|
5572
|
+
if (!remoteResult || remoteResult.remoteMigrationNumber !== latestMigration || remoteResult.hasDrift || remoteResult.checkpointMissingLocal) {
|
|
5573
|
+
if (remoteResult?.hasDrift) {
|
|
5574
|
+
logger.error("Remote schema drift detected:");
|
|
5575
|
+
logger.log(formatRemoteVerificationResults(remoteResults));
|
|
5576
|
+
}
|
|
5577
|
+
throw new Error("Refusing to re-baseline: the connected workspace remote schema must match the latest migration.");
|
|
5578
|
+
}
|
|
5579
|
+
};
|
|
5580
|
+
await assertConnectedWorkspaceReady(currentHistoryId, "initial");
|
|
5501
5581
|
logger.newline();
|
|
5502
|
-
logger.
|
|
5503
|
-
logger.log(
|
|
5504
|
-
|
|
5505
|
-
|
|
5582
|
+
logger.warn(`This will replace the migration history for ${styles.bold(target.namespace)}.`);
|
|
5583
|
+
logger.log(` Latest migration: ${formatMigrationNumber(latestMigration)}`);
|
|
5584
|
+
logger.log(" New history: 0000/schema.json only");
|
|
5585
|
+
logger.log(" migrate.ts and db.ts files will disappear from the working tree.");
|
|
5586
|
+
logger.log(" Committed migration files will remain in Git history. Preserve any uncommitted files before continuing.");
|
|
5587
|
+
logger.log(" Every other environment must already be at the latest migration.");
|
|
5588
|
+
logger.log(" The connected workspace checkpoint will be reset to 0000.");
|
|
5506
5589
|
logger.newline();
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
if (requested) {
|
|
5516
|
-
if (!namespacesWithMigrations.some((ns) => ns.namespace === requested)) throw new Error(`Namespace "${requested}" not found or does not have migrations configured`);
|
|
5517
|
-
return requested;
|
|
5590
|
+
if (!options.yes) {
|
|
5591
|
+
if (!await prompt.confirm({
|
|
5592
|
+
message: "Re-baseline this migration history and reset the connected workspace checkpoint?",
|
|
5593
|
+
default: false
|
|
5594
|
+
})) {
|
|
5595
|
+
logger.info("Operation cancelled.");
|
|
5596
|
+
return;
|
|
5597
|
+
}
|
|
5518
5598
|
}
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5599
|
+
assertValidMigrationFiles(target.migrationsDir, target.namespace);
|
|
5600
|
+
if (captureMigrationFileState([target])[target.namespace] !== initialFileState) throw new Error("Migration files changed while waiting for confirmation. Run the command again.");
|
|
5601
|
+
if (captureFileState(localSourceFiles()) !== initialLocalFileState) throw new Error("Local TailorDB type or config files changed. Run the command again.");
|
|
5602
|
+
assertLocalTypesReady();
|
|
5603
|
+
await assertConnectedWorkspaceReady(currentHistoryId, "confirmation");
|
|
5604
|
+
const rebaselineMarker = {
|
|
5605
|
+
historyId: createMigrationHistoryId(),
|
|
5606
|
+
replacedHistoryId: currentHistoryId,
|
|
5607
|
+
replacedLatestMigration: latestMigration
|
|
5608
|
+
};
|
|
5609
|
+
await activateBaseline(target.migrationsDir, latestSnapshot, target.namespace, rebaselineMarker);
|
|
5610
|
+
try {
|
|
5611
|
+
await updateMigrationLabel(client, workspaceId, target.namespace, 0, rebaselineMarker.historyId);
|
|
5612
|
+
} catch (error) {
|
|
5613
|
+
throw new Error(`The local migration history was re-baselined, but the connected workspace checkpoint could not be updated. Run ${setBaselineCommand}, or run ${deployCommand} with schema checks enabled after resolving the connection error.`, { cause: error });
|
|
5522
5614
|
}
|
|
5523
|
-
|
|
5615
|
+
logger.success(`Re-baselined ${styles.bold(target.namespace)} and reset the connected workspace checkpoint to 0000.`);
|
|
5524
5616
|
}
|
|
5525
|
-
const
|
|
5526
|
-
name: "
|
|
5527
|
-
description: "
|
|
5617
|
+
const rebaselineCommand = defineAppCommand({
|
|
5618
|
+
name: "rebaseline",
|
|
5619
|
+
description: "Collapse the full migration history into a new 0000 baseline.",
|
|
5620
|
+
notes: `Re-baselining removes migrations after 0000 from the working tree, records a new migration history ID, and resets the connected workspace checkpoint without changing its schema or data. Every environment must already have applied the latest migration before you run this command.`,
|
|
5528
5621
|
args: z.strictObject({
|
|
5529
|
-
...
|
|
5530
|
-
|
|
5531
|
-
positional: true,
|
|
5532
|
-
description: "Migration number to add a script to (e.g., 0001 or 1)"
|
|
5533
|
-
}),
|
|
5622
|
+
...deploymentArgs,
|
|
5623
|
+
...confirmationArgs,
|
|
5534
5624
|
namespace: arg(z.string().optional(), {
|
|
5535
5625
|
alias: "n",
|
|
5536
5626
|
description: "Target TailorDB namespace (required if multiple namespaces exist)"
|
|
5537
|
-
})
|
|
5538
|
-
"no-script": arg(z.boolean().optional(), { description: "Record that this migration intentionally runs without a migration script (requires --reason)" }),
|
|
5539
|
-
reason: arg(z.string().optional(), { description: "Reason why no migration script is needed (used with --no-script)" })
|
|
5627
|
+
})
|
|
5540
5628
|
}),
|
|
5541
5629
|
run: async (args) => {
|
|
5542
|
-
await
|
|
5630
|
+
await assertWritable({ profile: args.profile });
|
|
5631
|
+
await rebaseline({
|
|
5543
5632
|
configPath: args.config,
|
|
5544
|
-
number: args.number,
|
|
5545
5633
|
namespace: args.namespace,
|
|
5546
|
-
|
|
5547
|
-
|
|
5634
|
+
yes: args.yes,
|
|
5635
|
+
workspaceId: args["workspace-id"],
|
|
5636
|
+
profile: args.profile
|
|
5548
5637
|
});
|
|
5549
5638
|
}
|
|
5550
5639
|
});
|
|
5551
5640
|
|
|
5552
|
-
//#endregion
|
|
5553
|
-
//#region src/cli/commands/tailordb/migrate/remote-state.ts
|
|
5554
|
-
/**
|
|
5555
|
-
* Fetch the namespace's current migration number from its metadata labels.
|
|
5556
|
-
*
|
|
5557
|
-
* Only a namespace that has not been deployed yet reads as "no current
|
|
5558
|
-
* migration". Every other lookup failure must propagate: treating it as
|
|
5559
|
-
* unset would misreport a transient error as "no checkpoint", showing
|
|
5560
|
-
* every migration as pending to callers.
|
|
5561
|
-
* @param client - Operator client
|
|
5562
|
-
* @param trn - Namespace TRN
|
|
5563
|
-
* @returns Parsed current migration number, or null when unset or unparseable
|
|
5564
|
-
*/
|
|
5565
|
-
async function fetchRemoteMigrationNumber(client, trn) {
|
|
5566
|
-
const label = (await getOrNull(async () => {
|
|
5567
|
-
const { metadata } = await client.getMetadata({ trn });
|
|
5568
|
-
return metadata;
|
|
5569
|
-
}))?.labels[MIGRATION_LABEL_KEY];
|
|
5570
|
-
return label ? parseMigrationLabelNumber(label) : null;
|
|
5571
|
-
}
|
|
5572
|
-
|
|
5573
5641
|
//#endregion
|
|
5574
5642
|
//#region src/cli/commands/tailordb/migrate/set.ts
|
|
5575
5643
|
/**
|
|
@@ -5584,18 +5652,24 @@ async function set(options) {
|
|
|
5584
5652
|
const targetNamespace = target.namespace;
|
|
5585
5653
|
assertValidMigrationFiles(target.migrationsDir, targetNamespace);
|
|
5586
5654
|
assertMigrationNumberExists(target.migrationsDir, migrationNumber);
|
|
5655
|
+
const historyId = reconstructSnapshotFromMigrations(target.migrationsDir, 0)?.rebaseline?.historyId;
|
|
5587
5656
|
const client = await initOperatorClient(await loadAccessToken({ profile: options.profile }));
|
|
5588
5657
|
const trn = resourceTrn(await loadWorkspaceId({
|
|
5589
5658
|
workspaceId: options.workspaceId,
|
|
5590
5659
|
profile: options.profile
|
|
5591
5660
|
}), "tailordb", targetNamespace);
|
|
5592
|
-
const
|
|
5661
|
+
const currentState = await fetchRemoteMigrationState(client, trn);
|
|
5662
|
+
const current = currentState.number;
|
|
5593
5663
|
const currentMigration = current ?? 0;
|
|
5664
|
+
const currentHistoryId = currentState.historyIdInvalid ? "<invalid>" : currentState.historyId ?? "<unset>";
|
|
5665
|
+
const newHistoryId = historyId ?? "<unset>";
|
|
5594
5666
|
logger.newline();
|
|
5595
|
-
logger.warn("This operation will change
|
|
5667
|
+
logger.warn("This operation will change TailorDB migration state metadata.");
|
|
5596
5668
|
logger.log(`Namespace: ${styles.bold(targetNamespace)}`);
|
|
5597
5669
|
logger.log(`Current migration: ${current === null ? "<unset>" : styles.bold(formatMigrationNumber(current))}`);
|
|
5598
5670
|
logger.log(`New migration: ${styles.bold(formatMigrationNumber(migrationNumber))}`);
|
|
5671
|
+
logger.log(`Current migration history ID: ${styles.bold(currentHistoryId)}`);
|
|
5672
|
+
logger.log(`New migration history ID: ${styles.bold(newHistoryId)}`);
|
|
5599
5673
|
logger.newline();
|
|
5600
5674
|
if (migrationNumber < currentMigration) {
|
|
5601
5675
|
logger.warn(`Setting migration number backwards (${formatMigrationNumber(currentMigration)} → ${formatMigrationNumber(migrationNumber)}) will cause previous migrations to be re-executed on next apply.`);
|
|
@@ -5606,7 +5680,7 @@ async function set(options) {
|
|
|
5606
5680
|
}
|
|
5607
5681
|
if (!options.yes) {
|
|
5608
5682
|
if (!await prompt.confirm({
|
|
5609
|
-
message: "Continue with migration checkpoint update?",
|
|
5683
|
+
message: "Continue with migration checkpoint and history ID update?",
|
|
5610
5684
|
default: false
|
|
5611
5685
|
})) {
|
|
5612
5686
|
logger.info("Operation cancelled.");
|
|
@@ -5616,7 +5690,11 @@ async function set(options) {
|
|
|
5616
5690
|
}
|
|
5617
5691
|
await writeMetadataLabels(client, {
|
|
5618
5692
|
trn,
|
|
5619
|
-
labels: {
|
|
5693
|
+
labels: {
|
|
5694
|
+
[MIGRATION_LABEL_KEY]: sanitizeMigrationLabel(migrationNumber),
|
|
5695
|
+
...historyId ? { [MIGRATION_HISTORY_LABEL_KEY]: historyId } : {}
|
|
5696
|
+
},
|
|
5697
|
+
remove: historyId ? void 0 : [MIGRATION_HISTORY_LABEL_KEY]
|
|
5620
5698
|
});
|
|
5621
5699
|
logger.success(`Migration checkpoint set to ${styles.bold(formatMigrationNumber(migrationNumber))} for namespace ${styles.bold(targetNamespace)}`);
|
|
5622
5700
|
}
|
|
@@ -5662,17 +5740,67 @@ async function collectMigrationStatuses(options) {
|
|
|
5662
5740
|
if (namespacesWithMigrations.length === 0) throw new Error("No TailorDB services with migrations configuration found");
|
|
5663
5741
|
const targetNamespaces = options.namespace ? namespacesWithMigrations.filter((ns) => ns.namespace === options.namespace) : namespacesWithMigrations;
|
|
5664
5742
|
if (targetNamespaces.length === 0) throw new Error(`Namespace "${options.namespace}" not found or does not have migrations configured`);
|
|
5743
|
+
const localStates = /* @__PURE__ */ new Map();
|
|
5744
|
+
const localFailures = /* @__PURE__ */ new Map();
|
|
5745
|
+
for (const { namespace, migrationsDir } of targetNamespaces) try {
|
|
5746
|
+
const migrationFiles = getMigrationFiles(migrationsDir);
|
|
5747
|
+
const descriptions = /* @__PURE__ */ new Map();
|
|
5748
|
+
let historyId = null;
|
|
5749
|
+
for (const file of migrationFiles) {
|
|
5750
|
+
if (file.type === "schema") {
|
|
5751
|
+
const snapshot = loadSnapshot(file.path);
|
|
5752
|
+
if (file.number === 0) historyId = snapshot.rebaseline?.historyId ?? null;
|
|
5753
|
+
continue;
|
|
5754
|
+
}
|
|
5755
|
+
try {
|
|
5756
|
+
const diff = loadDiff(file.path);
|
|
5757
|
+
if (diff.description) descriptions.set(file.number, diff.description);
|
|
5758
|
+
} catch (error) {
|
|
5759
|
+
if (error instanceof UnsupportedMigrationFileVersionError) throw error;
|
|
5760
|
+
}
|
|
5761
|
+
}
|
|
5762
|
+
localStates.set(namespace, {
|
|
5763
|
+
migrationFiles,
|
|
5764
|
+
descriptions,
|
|
5765
|
+
historyId
|
|
5766
|
+
});
|
|
5767
|
+
} catch (error) {
|
|
5768
|
+
localFailures.set(namespace, error instanceof Error ? error.message : String(error));
|
|
5769
|
+
}
|
|
5770
|
+
if (localStates.size === 0) return targetNamespaces.map(({ namespace }) => ({
|
|
5771
|
+
status: "error",
|
|
5772
|
+
namespace,
|
|
5773
|
+
error: localFailures.get(namespace) ?? "Failed to read local migration history."
|
|
5774
|
+
}));
|
|
5665
5775
|
const client = await initOperatorClient(await loadAccessToken({ profile: options.profile }));
|
|
5666
5776
|
const workspaceId = await loadWorkspaceId({
|
|
5667
5777
|
workspaceId: options.workspaceId,
|
|
5668
5778
|
profile: options.profile
|
|
5669
5779
|
});
|
|
5670
5780
|
const rows = [];
|
|
5671
|
-
for (const { namespace
|
|
5781
|
+
for (const { namespace } of targetNamespaces) {
|
|
5782
|
+
if (localFailures.has(namespace)) {
|
|
5783
|
+
rows.push({
|
|
5784
|
+
status: "error",
|
|
5785
|
+
namespace,
|
|
5786
|
+
error: localFailures.get(namespace) ?? "Failed to read local migration history."
|
|
5787
|
+
});
|
|
5788
|
+
continue;
|
|
5789
|
+
}
|
|
5790
|
+
const localState = localStates.get(namespace);
|
|
5791
|
+
if (!localState) {
|
|
5792
|
+
rows.push({
|
|
5793
|
+
status: "error",
|
|
5794
|
+
namespace,
|
|
5795
|
+
error: "Failed to read local migration history."
|
|
5796
|
+
});
|
|
5797
|
+
continue;
|
|
5798
|
+
}
|
|
5799
|
+
const { migrationFiles, descriptions, historyId: localHistoryId } = localState;
|
|
5672
5800
|
const trn = resourceTrn(workspaceId, "tailordb", namespace);
|
|
5673
|
-
let
|
|
5801
|
+
let remoteState;
|
|
5674
5802
|
try {
|
|
5675
|
-
|
|
5803
|
+
remoteState = await fetchRemoteMigrationState(client, trn);
|
|
5676
5804
|
} catch (error) {
|
|
5677
5805
|
rows.push({
|
|
5678
5806
|
status: "error",
|
|
@@ -5681,19 +5809,28 @@ async function collectMigrationStatuses(options) {
|
|
|
5681
5809
|
});
|
|
5682
5810
|
continue;
|
|
5683
5811
|
}
|
|
5684
|
-
const currentMigration =
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
}
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5812
|
+
const currentMigration = remoteState.number ?? 0;
|
|
5813
|
+
if (remoteState.historyIdInvalid) {
|
|
5814
|
+
rows.push({
|
|
5815
|
+
status: "error",
|
|
5816
|
+
namespace,
|
|
5817
|
+
error: "Remote migration history ID is invalid."
|
|
5818
|
+
});
|
|
5819
|
+
continue;
|
|
5820
|
+
}
|
|
5821
|
+
if ((remoteState.number !== null || remoteState.historyId !== null) && remoteState.historyId !== localHistoryId) {
|
|
5822
|
+
rows.push({
|
|
5823
|
+
status: "error",
|
|
5824
|
+
namespace,
|
|
5825
|
+
error: `Remote migration history ID ${remoteState.historyId ?? "<unset>"} does not match local migration history ID ${localHistoryId ?? "<unset>"}.`
|
|
5826
|
+
});
|
|
5827
|
+
continue;
|
|
5828
|
+
}
|
|
5829
|
+
const pendingMigrations = migrationFiles.map((f) => f.number).filter((n, i, arr) => arr.indexOf(n) === i).toSorted((a, b) => a - b).filter((n) => n > currentMigration).map((num) => ({
|
|
5830
|
+
number: num,
|
|
5831
|
+
label: formatMigrationNumber(num),
|
|
5832
|
+
...descriptions.has(num) ? { description: descriptions.get(num) } : {}
|
|
5833
|
+
}));
|
|
5697
5834
|
rows.push({
|
|
5698
5835
|
status: "ok",
|
|
5699
5836
|
namespace,
|
|
@@ -5709,7 +5846,7 @@ function printMigrationStatuses(rows) {
|
|
|
5709
5846
|
logger.newline();
|
|
5710
5847
|
logger.info(`Namespace: ${styles.bold(row.namespace)}`);
|
|
5711
5848
|
if (isStatusFailure(row)) {
|
|
5712
|
-
logger.error(`
|
|
5849
|
+
logger.error(` Migration status error: ${row.error}`);
|
|
5713
5850
|
continue;
|
|
5714
5851
|
}
|
|
5715
5852
|
logger.log(` Current migration: ${styles.bold(row.currentMigrationLabel)}`);
|
|
@@ -5733,13 +5870,13 @@ async function status(options) {
|
|
|
5733
5870
|
const failures = rows.filter(isStatusFailure);
|
|
5734
5871
|
if (failures.length > 0) {
|
|
5735
5872
|
const namespaces = failures.map((f) => f.namespace).join(", ");
|
|
5736
|
-
throw new Error(`
|
|
5873
|
+
throw new Error(`Migration status check failed for ${failures.length} namespace${failures.length === 1 ? "" : "s"}: ${namespaces}`);
|
|
5737
5874
|
}
|
|
5738
5875
|
}
|
|
5739
5876
|
const statusCommand = defineAppCommand({
|
|
5740
5877
|
name: "status",
|
|
5741
5878
|
description: "Show the current migration status for TailorDB namespaces, including applied and pending migrations.",
|
|
5742
|
-
notes: "
|
|
5879
|
+
notes: "Every local migration file is checked for a compatible format version, and deployed migration history IDs must match the local baseline. Compatibility errors, history mismatches, and metadata lookup failures are reported per namespace and make the command exit non-zero; only a not-yet-deployed namespace is treated as having no applied migrations.",
|
|
5743
5880
|
args: z.strictObject({
|
|
5744
5881
|
...deploymentArgs,
|
|
5745
5882
|
namespace: arg(z.string().optional(), {
|
|
@@ -5808,7 +5945,7 @@ async function fetchRemoteTypes(client, workspaceId, namespace) {
|
|
|
5808
5945
|
async function assertMigrationsReproduceLocalTypes(loaded, target) {
|
|
5809
5946
|
const { config, plugins } = loaded;
|
|
5810
5947
|
const pluginManager = plugins.length > 0 ? new PluginManager(plugins) : void 0;
|
|
5811
|
-
const { defineApplication, generatePluginFilesIfNeeded } = await import("../application-
|
|
5948
|
+
const { defineApplication, generatePluginFilesIfNeeded } = await import("../application-BxLLiLsr.mjs");
|
|
5812
5949
|
const application = defineApplication({
|
|
5813
5950
|
config,
|
|
5814
5951
|
pluginManager
|
|
@@ -5820,7 +5957,7 @@ async function assertMigrationsReproduceLocalTypes(loaded, target) {
|
|
|
5820
5957
|
await service.processNamespacePlugins();
|
|
5821
5958
|
}
|
|
5822
5959
|
const pluginExecutorFiles = generatePluginFilesIfNeeded(pluginManager, application.tailorDBServices, config.path);
|
|
5823
|
-
const executorService = application.executorService ?? (pluginExecutorFiles.length > 0 ? (await import("../service-
|
|
5960
|
+
const executorService = application.executorService ?? (pluginExecutorFiles.length > 0 ? (await import("../service-_XmjSEGr.mjs")).createExecutorService({
|
|
5824
5961
|
config: { files: [] },
|
|
5825
5962
|
baseDir: path.dirname(config.path)
|
|
5826
5963
|
}) : void 0);
|
|
@@ -5965,7 +6102,11 @@ async function sync(options) {
|
|
|
5965
6102
|
})));
|
|
5966
6103
|
await writeMetadataLabels(client, {
|
|
5967
6104
|
trn,
|
|
5968
|
-
labels: {
|
|
6105
|
+
labels: {
|
|
6106
|
+
[MIGRATION_LABEL_KEY]: sanitizeMigrationLabel(targetVersion),
|
|
6107
|
+
...snapshot.rebaseline?.historyId ? { [MIGRATION_HISTORY_LABEL_KEY]: snapshot.rebaseline.historyId } : {}
|
|
6108
|
+
},
|
|
6109
|
+
remove: snapshot.rebaseline?.historyId ? void 0 : [MIGRATION_HISTORY_LABEL_KEY]
|
|
5969
6110
|
});
|
|
5970
6111
|
logger.success(`Synced namespace ${styles.bold(target.namespace)} to migration ${styles.bold(formatMigrationNumber(targetVersion))}.`);
|
|
5971
6112
|
if (targetVersion < latest) {
|
|
@@ -6001,23 +6142,634 @@ const syncCommand = defineAppCommand({
|
|
|
6001
6142
|
}
|
|
6002
6143
|
});
|
|
6003
6144
|
|
|
6145
|
+
//#endregion
|
|
6146
|
+
//#region src/cli/commands/tailordb/migrate/test-runtime.ts
|
|
6147
|
+
const CLONE_POLL_INTERVAL = 1e3;
|
|
6148
|
+
const CLONE_TIMEOUT = 300 * 1e3;
|
|
6149
|
+
function delay(milliseconds) {
|
|
6150
|
+
return milliseconds > 0 ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve();
|
|
6151
|
+
}
|
|
6152
|
+
function assertSeedDataDirectory(dataDir) {
|
|
6153
|
+
let stats;
|
|
6154
|
+
try {
|
|
6155
|
+
stats = fs.statSync(dataDir);
|
|
6156
|
+
} catch (error) {
|
|
6157
|
+
if (error.code === "ENOENT") throw new Error(`Seed data directory not found: ${dataDir}. Run 'tailor generate' before testing migrations.`, { cause: error });
|
|
6158
|
+
throw error;
|
|
6159
|
+
}
|
|
6160
|
+
if (!stats.isDirectory()) throw new Error(`Seed data path is not a directory: ${dataDir}`);
|
|
6161
|
+
}
|
|
6162
|
+
function assertAssertionScript(assertionPath) {
|
|
6163
|
+
const resolvedPath = path.resolve(assertionPath);
|
|
6164
|
+
let stats;
|
|
6165
|
+
try {
|
|
6166
|
+
stats = fs.statSync(resolvedPath);
|
|
6167
|
+
} catch (error) {
|
|
6168
|
+
if (error.code === "ENOENT") throw new Error(`Migration assertion script not found: ${resolvedPath}`, { cause: error });
|
|
6169
|
+
throw error;
|
|
6170
|
+
}
|
|
6171
|
+
if (!stats.isFile()) throw new Error(`Migration assertion path is not a file: ${resolvedPath}`);
|
|
6172
|
+
}
|
|
6173
|
+
/**
|
|
6174
|
+
* Require a designated clone target to share the source workspace's region.
|
|
6175
|
+
* @param sourceRegion - Source workspace region
|
|
6176
|
+
* @param targetRegion - Target workspace region
|
|
6177
|
+
*/
|
|
6178
|
+
function assertCloneTargetRegion(sourceRegion, targetRegion) {
|
|
6179
|
+
if (sourceRegion !== targetRegion) throw new Error(`Clone mode requires source and target workspaces in the same region (source: ${sourceRegion}, target: ${targetRegion}).`);
|
|
6180
|
+
}
|
|
6181
|
+
/**
|
|
6182
|
+
* Require the migration test target workspace to differ from the source workspace.
|
|
6183
|
+
* @param sourceWorkspaceId - Source workspace ID
|
|
6184
|
+
* @param targetWorkspaceId - Designated target workspace ID, if any
|
|
6185
|
+
*/
|
|
6186
|
+
function assertTargetWorkspaceDiffers(sourceWorkspaceId, targetWorkspaceId) {
|
|
6187
|
+
if (targetWorkspaceId?.toLowerCase() === sourceWorkspaceId.toLowerCase()) throw new Error("The migration test target workspace must differ from the source workspace.");
|
|
6188
|
+
}
|
|
6189
|
+
/**
|
|
6190
|
+
* Resolve the namespace an assertion script runs against.
|
|
6191
|
+
* @param pendingNamespaces - Namespaces with pending migrations
|
|
6192
|
+
* @param assertionNamespace - Explicit namespace from `--assert-namespace`, if any
|
|
6193
|
+
* @returns Resolved assertion namespace
|
|
6194
|
+
*/
|
|
6195
|
+
function resolveAssertionNamespace(pendingNamespaces, assertionNamespace) {
|
|
6196
|
+
const namespace = assertionNamespace ?? (pendingNamespaces.length === 1 ? pendingNamespaces[0] : void 0);
|
|
6197
|
+
if (!namespace) throw new Error("--assert-namespace is required when pending migrations span multiple namespaces.");
|
|
6198
|
+
if (!pendingNamespaces.includes(namespace)) throw new Error(`Assertion namespace "${namespace}" has no pending migrations.`);
|
|
6199
|
+
return namespace;
|
|
6200
|
+
}
|
|
6201
|
+
/**
|
|
6202
|
+
* Build the schema overrides used before migration-test data is loaded.
|
|
6203
|
+
* @param options - Source workspace, local inputs, and migration baselines
|
|
6204
|
+
* @returns Schema snapshots keyed by namespace
|
|
6205
|
+
*/
|
|
6206
|
+
async function createMigrationTestBaselineSnapshots(options) {
|
|
6207
|
+
const snapshots = new Map([...options.baselines].map(([namespace, baseline]) => [namespace, baseline.snapshot]));
|
|
6208
|
+
if (options.dataMode !== "clone") return snapshots;
|
|
6209
|
+
const unmigratedInputs = options.inputs.filter((input) => !snapshots.has(input.namespace));
|
|
6210
|
+
const remoteSnapshots = await Promise.all(unmigratedInputs.map((input) => fetchRemoteSchemaSnapshot(options.client, options.workspaceId, input.namespace)));
|
|
6211
|
+
unmigratedInputs.forEach((input, index) => {
|
|
6212
|
+
snapshots.set(input.namespace, assertDefined(remoteSnapshots[index], `remote snapshot missing for "${input.namespace}"`));
|
|
6213
|
+
});
|
|
6214
|
+
return snapshots;
|
|
6215
|
+
}
|
|
6216
|
+
/**
|
|
6217
|
+
* Wait for an application-data clone operation to reach a terminal state.
|
|
6218
|
+
* @param client - Operator client
|
|
6219
|
+
* @param options - Clone operation identifiers and polling limits
|
|
6220
|
+
*/
|
|
6221
|
+
async function waitForCloneApplicationData(client, options) {
|
|
6222
|
+
const pollInterval = options.pollInterval ?? CLONE_POLL_INTERVAL;
|
|
6223
|
+
const timeout = options.timeout ?? CLONE_TIMEOUT;
|
|
6224
|
+
const deadline = Date.now() + timeout;
|
|
6225
|
+
while (Date.now() <= deadline) {
|
|
6226
|
+
const operation = await client.getCloneApplicationDataOperation({
|
|
6227
|
+
sourceWorkspaceId: options.sourceWorkspaceId,
|
|
6228
|
+
targetWorkspaceId: options.targetWorkspaceId,
|
|
6229
|
+
operationId: options.operationId
|
|
6230
|
+
});
|
|
6231
|
+
if (operation.status === CloneOperationStatus.COMPLETED) return;
|
|
6232
|
+
if (operation.status === CloneOperationStatus.FAILED) throw new Error(`Application data clone failed: ${operation.errorMessage || "unknown platform error"}`);
|
|
6233
|
+
if (operation.status !== CloneOperationStatus.PENDING && operation.status !== CloneOperationStatus.PROCESSING) throw new Error(`Application data clone returned unexpected status ${operation.status}.`);
|
|
6234
|
+
await delay(pollInterval);
|
|
6235
|
+
}
|
|
6236
|
+
throw new Error(`Application data clone timed out after ${Math.round(timeout / 1e3)} seconds.`);
|
|
6237
|
+
}
|
|
6238
|
+
/**
|
|
6239
|
+
* Load generated JSONL seed rows for the types in a baseline snapshot.
|
|
6240
|
+
* @param dataDir - Directory containing `<Type>.jsonl` files
|
|
6241
|
+
* @param typeNames - Baseline type names to load
|
|
6242
|
+
* @param snapshot - Baseline schema used to remove fields introduced by pending migrations
|
|
6243
|
+
* @returns Seed rows keyed by type name
|
|
6244
|
+
*/
|
|
6245
|
+
function loadSnapshotSeedData(dataDir, typeNames, snapshot) {
|
|
6246
|
+
const data = {};
|
|
6247
|
+
for (const typeName of typeNames) {
|
|
6248
|
+
const snapshotType = snapshot?.types[typeName];
|
|
6249
|
+
const jsonlPath = path.join(dataDir, `${typeName}.jsonl`);
|
|
6250
|
+
let content;
|
|
6251
|
+
try {
|
|
6252
|
+
content = fs.readFileSync(jsonlPath, "utf8").trim();
|
|
6253
|
+
} catch (error) {
|
|
6254
|
+
if (error.code === "ENOENT") {
|
|
6255
|
+
data[typeName] = [];
|
|
6256
|
+
continue;
|
|
6257
|
+
}
|
|
6258
|
+
throw error;
|
|
6259
|
+
}
|
|
6260
|
+
data[typeName] = content ? content.split("\n").map((line, index) => {
|
|
6261
|
+
let value;
|
|
6262
|
+
try {
|
|
6263
|
+
value = JSON.parse(line);
|
|
6264
|
+
} catch (error) {
|
|
6265
|
+
throw new Error(`Invalid JSON in ${jsonlPath} at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
6266
|
+
}
|
|
6267
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid seed row in ${jsonlPath} at line ${index + 1}: expected a JSON object`);
|
|
6268
|
+
const row = value;
|
|
6269
|
+
return snapshotType ? projectSeedObject(row, snapshotType.fields, /* @__PURE__ */ new Set(["id"])) : row;
|
|
6270
|
+
}) : [];
|
|
6271
|
+
}
|
|
6272
|
+
return data;
|
|
6273
|
+
}
|
|
6274
|
+
function projectSeedObject(value, fields, implicitFields = /* @__PURE__ */ new Set()) {
|
|
6275
|
+
return Object.fromEntries(Object.entries(value).flatMap(([fieldName, fieldValue]) => {
|
|
6276
|
+
const field = fields[fieldName];
|
|
6277
|
+
if (!field) return implicitFields.has(fieldName) ? [[fieldName, fieldValue]] : [];
|
|
6278
|
+
return [[fieldName, projectSeedValue(fieldValue, field)]];
|
|
6279
|
+
}));
|
|
6280
|
+
}
|
|
6281
|
+
function projectSeedValue(value, field) {
|
|
6282
|
+
const fields = field.fields;
|
|
6283
|
+
if (!fields) return value;
|
|
6284
|
+
if (field.array) {
|
|
6285
|
+
if (!Array.isArray(value)) return value;
|
|
6286
|
+
return value.map((entry) => isJsonObject(entry) ? projectSeedObject(entry, fields) : entry);
|
|
6287
|
+
}
|
|
6288
|
+
return isJsonObject(value) ? projectSeedObject(value, fields) : value;
|
|
6289
|
+
}
|
|
6290
|
+
function isJsonObject(value) {
|
|
6291
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6292
|
+
}
|
|
6293
|
+
/**
|
|
6294
|
+
* Derive insertion order and self-referencing types from a baseline snapshot.
|
|
6295
|
+
* @param snapshot - Baseline schema snapshot
|
|
6296
|
+
* @returns Dependency-ordered type names and self-referencing types
|
|
6297
|
+
*/
|
|
6298
|
+
function sortSeedTypesForSnapshot(snapshot) {
|
|
6299
|
+
const typeNames = Object.keys(snapshot.types);
|
|
6300
|
+
const available = new Set(typeNames);
|
|
6301
|
+
const dependencies = /* @__PURE__ */ new Map();
|
|
6302
|
+
const selfRefTypes = [];
|
|
6303
|
+
for (const [typeName, type] of Object.entries(snapshot.types)) {
|
|
6304
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
6305
|
+
for (const field of Object.values(type.fields)) {
|
|
6306
|
+
const target = field.foreignKeyType;
|
|
6307
|
+
if (!target) continue;
|
|
6308
|
+
if (target === typeName) selfRefTypes.push(typeName);
|
|
6309
|
+
else if (available.has(target)) referenced.add(target);
|
|
6310
|
+
}
|
|
6311
|
+
dependencies.set(typeName, [...referenced]);
|
|
6312
|
+
}
|
|
6313
|
+
const visited = /* @__PURE__ */ new Set();
|
|
6314
|
+
const order = [];
|
|
6315
|
+
const visit = (typeName) => {
|
|
6316
|
+
if (visited.has(typeName)) return;
|
|
6317
|
+
visited.add(typeName);
|
|
6318
|
+
for (const dependency of dependencies.get(typeName) ?? []) visit(dependency);
|
|
6319
|
+
order.push(typeName);
|
|
6320
|
+
};
|
|
6321
|
+
for (const typeName of typeNames) visit(typeName);
|
|
6322
|
+
return {
|
|
6323
|
+
order,
|
|
6324
|
+
selfRefTypes
|
|
6325
|
+
};
|
|
6326
|
+
}
|
|
6327
|
+
function temporaryWorkspaceName() {
|
|
6328
|
+
return `migration-test-${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:T.Z]/g, "").slice(0, 14)}-${randomBytes(3).toString("hex")}`;
|
|
6329
|
+
}
|
|
6330
|
+
function stateOrThrow(state) {
|
|
6331
|
+
return assertDefined(state, "migration test runtime was used before preparation");
|
|
6332
|
+
}
|
|
6333
|
+
/**
|
|
6334
|
+
* Require the source workspace to still match the prepared baselines.
|
|
6335
|
+
*
|
|
6336
|
+
* The source is verified during preparation, but the baseline deploy runs in
|
|
6337
|
+
* between; a source deploy in that window would make the clone diverge from
|
|
6338
|
+
* the already-deployed baseline schemas.
|
|
6339
|
+
* @param state - Prepared migration test runtime state
|
|
6340
|
+
* @param prepared - Baselines captured during preparation
|
|
6341
|
+
* @param sourceWorkspaceId - Source workspace ID
|
|
6342
|
+
*/
|
|
6343
|
+
async function assertSourceBaselineFresh(state, prepared, sourceWorkspaceId) {
|
|
6344
|
+
const loaded = state.loaded;
|
|
6345
|
+
const namespaces = getNamespacesWithMigrations(loaded.config, path.dirname(loaded.config.path));
|
|
6346
|
+
const inputs = loaded.application.tailorDBServices.map(toTailorDBDeployInput);
|
|
6347
|
+
const invalidRemote = (await verifyRemoteSchema(state.client, sourceWorkspaceId, namespaces, loaded.config, inputs)).find((check) => check.hasDrift || check.checkpointMissingLocal || check.checkpointRepair);
|
|
6348
|
+
if (invalidRemote) throw new Error(`Source namespace "${invalidRemote.namespace}" changed after migration test preparation. Run the migration test again.`);
|
|
6349
|
+
for (const namespace of namespaces) {
|
|
6350
|
+
const baseline = prepared.baselines.get(namespace.namespace);
|
|
6351
|
+
if (!baseline) continue;
|
|
6352
|
+
const migrationNumber = await fetchRemoteMigrationNumber(state.client, resourceTrn(sourceWorkspaceId, "tailordb", namespace.namespace));
|
|
6353
|
+
if (migrationNumber !== baseline.migrationNumber) throw new Error(`Source namespace "${namespace.namespace}" moved from migration ${baseline.migrationNumber} to ${migrationNumber ?? "none"} after migration test preparation. Run the migration test again.`);
|
|
6354
|
+
}
|
|
6355
|
+
const unmigratedInputs = inputs.filter((input) => !prepared.baselines.has(input.namespace));
|
|
6356
|
+
for (const input of unmigratedInputs) {
|
|
6357
|
+
const snapshot = prepared.baselineSnapshots.get(input.namespace);
|
|
6358
|
+
if (!snapshot) continue;
|
|
6359
|
+
if (compareSnapshots(snapshot, await fetchRemoteSchemaSnapshot(state.client, sourceWorkspaceId, input.namespace)).changes.length > 0) throw new Error(`Source namespace "${input.namespace}" schema changed after migration test preparation. Run the migration test again.`);
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
/**
|
|
6363
|
+
* Delete the target workspace's user profile config when one exists.
|
|
6364
|
+
*
|
|
6365
|
+
* A retained target may carry a user profile config referencing types the
|
|
6366
|
+
* baseline deploy replaces; deleting it up front lets the baseline's planAuth
|
|
6367
|
+
* see no existing config instead of reordering delete phases.
|
|
6368
|
+
* @param state - Prepared migration test runtime state
|
|
6369
|
+
* @param targetWorkspaceId - Target workspace ID
|
|
6370
|
+
*/
|
|
6371
|
+
async function deleteExistingUserProfileConfig(state, targetWorkspaceId) {
|
|
6372
|
+
const auth = state.loaded.application.authService;
|
|
6373
|
+
if (!auth) return;
|
|
6374
|
+
const namespaceName = auth.config.name;
|
|
6375
|
+
if (!await getOrNull(() => state.client.getUserProfileConfig({
|
|
6376
|
+
workspaceId: targetWorkspaceId,
|
|
6377
|
+
namespaceName
|
|
6378
|
+
}))) return;
|
|
6379
|
+
await state.client.deleteUserProfileConfig({
|
|
6380
|
+
workspaceId: targetWorkspaceId,
|
|
6381
|
+
namespaceName
|
|
6382
|
+
});
|
|
6383
|
+
}
|
|
6384
|
+
function migrationConfig(loaded, namespace) {
|
|
6385
|
+
return (loaded.config.db?.[namespace])?.migration;
|
|
6386
|
+
}
|
|
6387
|
+
function authExecutionContext(state, namespace, explicitMachineUser) {
|
|
6388
|
+
const auth = state.loaded.application.authService;
|
|
6389
|
+
if (!auth) throw new Error("Auth configuration is required to execute migration test scripts.");
|
|
6390
|
+
const machineUserName = explicitMachineUser ?? getMigrationMachineUser(migrationConfig(state.loaded, namespace), auth.config.machineUsers ? Object.keys(auth.config.machineUsers) : void 0);
|
|
6391
|
+
if (!machineUserName) throw new Error(`No machine user is available for namespace "${namespace}". Pass --machine-user or configure one in auth or db.${namespace}.migration.`);
|
|
6392
|
+
return {
|
|
6393
|
+
authNamespace: auth.config.name,
|
|
6394
|
+
machineUserName
|
|
6395
|
+
};
|
|
6396
|
+
}
|
|
6397
|
+
async function prepareMigrationTest(options) {
|
|
6398
|
+
const loaded = await loadApplicationNamespaces({ configPath: options.configPath });
|
|
6399
|
+
const configDir = path.dirname(loaded.config.path);
|
|
6400
|
+
const namespaces = getNamespacesWithMigrations(loaded.config, configDir);
|
|
6401
|
+
if (namespaces.length === 0) throw new Error("No TailorDB services with migrations configuration found.");
|
|
6402
|
+
for (const namespace of namespaces) assertValidMigrationFiles(namespace.migrationsDir, namespace.namespace);
|
|
6403
|
+
let seedContext;
|
|
6404
|
+
if (options.data === "seed") {
|
|
6405
|
+
seedContext = await loadSeedContext({ configPath: loaded.config.path });
|
|
6406
|
+
assertSeedDataDirectory(path.join(seedContext.distPath, "data"));
|
|
6407
|
+
}
|
|
6408
|
+
if (options.assertionPath) assertAssertionScript(options.assertionPath);
|
|
6409
|
+
const inputs = loaded.application.tailorDBServices.map(toTailorDBDeployInput);
|
|
6410
|
+
const client = await initOperatorClient(await loadAccessToken({ profile: options.profile }), await loadPlatformClientConfig({ profile: options.profile }));
|
|
6411
|
+
const sourceWorkspaceId = await loadWorkspaceId({
|
|
6412
|
+
workspaceId: options.workspaceId,
|
|
6413
|
+
profile: options.profile
|
|
6414
|
+
});
|
|
6415
|
+
assertTargetWorkspaceDiffers(sourceWorkspaceId, options.targetWorkspaceId);
|
|
6416
|
+
const [workspaceResponse, applicationResponse, targetWorkspaceResponse] = await Promise.all([
|
|
6417
|
+
client.getWorkspace({ workspaceId: sourceWorkspaceId }),
|
|
6418
|
+
client.getApplication({
|
|
6419
|
+
workspaceId: sourceWorkspaceId,
|
|
6420
|
+
applicationName: loaded.config.name
|
|
6421
|
+
}),
|
|
6422
|
+
options.data === "clone" && options.targetWorkspaceId ? client.getWorkspace({ workspaceId: options.targetWorkspaceId }) : Promise.resolve(void 0)
|
|
6423
|
+
]);
|
|
6424
|
+
const sourceWorkspace = assertDefined(workspaceResponse.workspace, `source workspace "${sourceWorkspaceId}" not found`);
|
|
6425
|
+
assertDefined(applicationResponse.application, `application "${loaded.config.name}" not found in source workspace`);
|
|
6426
|
+
const designatedTarget = targetWorkspaceResponse ? assertDefined(targetWorkspaceResponse.workspace, `target workspace "${options.targetWorkspaceId}" not found`) : void 0;
|
|
6427
|
+
const remoteChecks = await verifyRemoteSchema(client, sourceWorkspaceId, namespaces, loaded.config, inputs);
|
|
6428
|
+
const invalidRemote = remoteChecks.find((check) => check.hasDrift || check.checkpointMissingLocal);
|
|
6429
|
+
if (invalidRemote) throw new Error(`Source namespace "${invalidRemote.namespace}" does not match its migration checkpoint. Run 'tailor tailordb migration validate' first.`);
|
|
6430
|
+
const pendingRepair = remoteChecks.find((check) => check.checkpointRepair);
|
|
6431
|
+
if (pendingRepair) throw new Error(`Source namespace "${pendingRepair.namespace}" has a pending migration checkpoint repair. Run 'tailor deploy' against the source workspace first.`);
|
|
6432
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
6433
|
+
const targetSnapshots = /* @__PURE__ */ new Map();
|
|
6434
|
+
const pendingNamespaces = [];
|
|
6435
|
+
for (const namespace of namespaces) {
|
|
6436
|
+
const migrationNumber = await fetchRemoteMigrationNumber(client, resourceTrn(sourceWorkspaceId, "tailordb", namespace.namespace));
|
|
6437
|
+
if (migrationNumber === null) throw new Error(`Source namespace "${namespace.namespace}" has no sdk-migration checkpoint. Deploy or set its migration checkpoint before testing.`);
|
|
6438
|
+
const latest = getLatestMigrationNumber(namespace.migrationsDir);
|
|
6439
|
+
if (migrationNumber > latest) throw new Error(`Source namespace "${namespace.namespace}" is at migration ${migrationNumber}, but the local history ends at ${latest}.`);
|
|
6440
|
+
const snapshot = reconstructSnapshotFromMigrations(namespace.migrationsDir, migrationNumber);
|
|
6441
|
+
if (!snapshot) throw new Error(`No migration baseline snapshot found for namespace "${namespace.namespace}".`);
|
|
6442
|
+
baselines.set(namespace.namespace, {
|
|
6443
|
+
migrationNumber,
|
|
6444
|
+
snapshot,
|
|
6445
|
+
historyId: snapshot.rebaseline?.historyId ?? null
|
|
6446
|
+
});
|
|
6447
|
+
const targetSnapshot = reconstructSnapshotFromMigrations(namespace.migrationsDir, latest);
|
|
6448
|
+
if (!targetSnapshot) throw new Error(`No target migration snapshot found for namespace "${namespace.namespace}".`);
|
|
6449
|
+
targetSnapshots.set(namespace.namespace, targetSnapshot);
|
|
6450
|
+
if (migrationNumber < latest) pendingNamespaces.push(namespace.namespace);
|
|
6451
|
+
}
|
|
6452
|
+
if (pendingNamespaces.length === 0) throw new Error("No pending TailorDB migrations found in the source workspace.");
|
|
6453
|
+
const baselineSnapshots = await createMigrationTestBaselineSnapshots({
|
|
6454
|
+
client,
|
|
6455
|
+
workspaceId: sourceWorkspaceId,
|
|
6456
|
+
dataMode: options.data,
|
|
6457
|
+
inputs,
|
|
6458
|
+
baselines
|
|
6459
|
+
});
|
|
6460
|
+
const prepared = {
|
|
6461
|
+
sourceWorkspaceId,
|
|
6462
|
+
sourceApplicationName: loaded.config.name,
|
|
6463
|
+
temporaryWorkspace: {
|
|
6464
|
+
name: temporaryWorkspaceName(),
|
|
6465
|
+
region: sourceWorkspace.region,
|
|
6466
|
+
...sourceWorkspace.organizationId ? { organizationId: sourceWorkspace.organizationId } : {},
|
|
6467
|
+
...sourceWorkspace.folderId ? { folderId: sourceWorkspace.folderId } : {}
|
|
6468
|
+
},
|
|
6469
|
+
baselines,
|
|
6470
|
+
baselineSnapshots,
|
|
6471
|
+
targetSnapshots,
|
|
6472
|
+
pendingNamespaces,
|
|
6473
|
+
...designatedTarget ? { designatedTarget: {
|
|
6474
|
+
id: designatedTarget.id,
|
|
6475
|
+
region: designatedTarget.region
|
|
6476
|
+
} } : {}
|
|
6477
|
+
};
|
|
6478
|
+
return {
|
|
6479
|
+
state: {
|
|
6480
|
+
client,
|
|
6481
|
+
loaded,
|
|
6482
|
+
options,
|
|
6483
|
+
...seedContext ? { seedContext } : {}
|
|
6484
|
+
},
|
|
6485
|
+
prepared
|
|
6486
|
+
};
|
|
6487
|
+
}
|
|
6488
|
+
/**
|
|
6489
|
+
* Create the platform operations used by `tailordb migration test`.
|
|
6490
|
+
* @returns Migration test dependencies backed by the Operator API
|
|
6491
|
+
*/
|
|
6492
|
+
function createMigrationTestDependencies() {
|
|
6493
|
+
let runtimeState;
|
|
6494
|
+
return {
|
|
6495
|
+
prepare: async (options) => {
|
|
6496
|
+
const { state, prepared } = await prepareMigrationTest(options);
|
|
6497
|
+
runtimeState = state;
|
|
6498
|
+
return prepared;
|
|
6499
|
+
},
|
|
6500
|
+
createWorkspace: async (prepared) => {
|
|
6501
|
+
const workspace = await createValidatedWorkspaceWithClient(stateOrThrow(runtimeState).client, prepared.temporaryWorkspace);
|
|
6502
|
+
logger.info(`Created temporary workspace ${workspace.name} (${workspace.id}).`);
|
|
6503
|
+
return {
|
|
6504
|
+
id: workspace.id,
|
|
6505
|
+
name: workspace.name
|
|
6506
|
+
};
|
|
6507
|
+
},
|
|
6508
|
+
deployBaseline: async ({ prepared, targetWorkspaceId }) => {
|
|
6509
|
+
const state = stateOrThrow(runtimeState);
|
|
6510
|
+
await deleteExistingUserProfileConfig(state, targetWorkspaceId);
|
|
6511
|
+
await deployMigrationTestBaseline({
|
|
6512
|
+
configPath: state.loaded.config.path,
|
|
6513
|
+
profile: state.options.profile,
|
|
6514
|
+
workspaceId: targetWorkspaceId,
|
|
6515
|
+
yes: true
|
|
6516
|
+
}, prepared.baselines, prepared.baselineSnapshots);
|
|
6517
|
+
for (const [namespace, baseline] of prepared.baselines) await updateMigrationLabel(state.client, targetWorkspaceId, namespace, baseline.migrationNumber, baseline.historyId ?? void 0);
|
|
6518
|
+
},
|
|
6519
|
+
seedData: async ({ prepared, targetWorkspaceId }) => {
|
|
6520
|
+
const state = stateOrThrow(runtimeState);
|
|
6521
|
+
const seedContext = assertDefined(state.seedContext, "seed context missing after migration test preparation");
|
|
6522
|
+
const dataDir = path.join(seedContext.distPath, "data");
|
|
6523
|
+
assertSeedDataDirectory(dataDir);
|
|
6524
|
+
const seedSnapshots = new Map(state.loaded.application.tailorDBServices.map((service) => {
|
|
6525
|
+
const baseline = prepared.baselines.get(service.namespace);
|
|
6526
|
+
const input = toTailorDBDeployInput(service);
|
|
6527
|
+
return [service.namespace, baseline?.snapshot ?? normalizeSchemaSnapshot({
|
|
6528
|
+
version: 1,
|
|
6529
|
+
namespace: service.namespace,
|
|
6530
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6531
|
+
types: input.types
|
|
6532
|
+
})];
|
|
6533
|
+
}));
|
|
6534
|
+
for (const [namespace, snapshot] of seedSnapshots) {
|
|
6535
|
+
const { order, selfRefTypes } = sortSeedTypesForSnapshot(snapshot);
|
|
6536
|
+
const data = loadSnapshotSeedData(dataDir, order, snapshot);
|
|
6537
|
+
const typesWithData = order.filter((typeName) => (data[typeName]?.length ?? 0) > 0);
|
|
6538
|
+
if (typesWithData.length === 0) continue;
|
|
6539
|
+
const bundled = await bundleSeedScript(namespace, typesWithData, path.dirname(state.loaded.config.path));
|
|
6540
|
+
const chunks = chunkSeedData({
|
|
6541
|
+
data,
|
|
6542
|
+
order,
|
|
6543
|
+
codeByteSize: new TextEncoder().encode(bundled.bundledCode).length
|
|
6544
|
+
});
|
|
6545
|
+
const auth = authExecutionContext(state, namespace, state.options.machineUser ?? seedContext.machineUserName);
|
|
6546
|
+
for (const chunk of chunks) {
|
|
6547
|
+
const execution = await executeScript({
|
|
6548
|
+
client: state.client,
|
|
6549
|
+
workspaceId: targetWorkspaceId,
|
|
6550
|
+
name: `migration-test-seed-${namespace}.ts`,
|
|
6551
|
+
code: bundled.bundledCode,
|
|
6552
|
+
arg: {
|
|
6553
|
+
data: chunk.data,
|
|
6554
|
+
order: chunk.order,
|
|
6555
|
+
selfRefTypes,
|
|
6556
|
+
upsert: false
|
|
6557
|
+
},
|
|
6558
|
+
invoker: {
|
|
6559
|
+
namespace: auth.authNamespace,
|
|
6560
|
+
machineUserName: auth.machineUserName
|
|
6561
|
+
}
|
|
6562
|
+
});
|
|
6563
|
+
if (!execution.success) throw new Error(execution.error ?? `Seed execution failed for namespace "${namespace}".`);
|
|
6564
|
+
if (JSON.parse(execution.result || "{}").success !== true) throw new Error(`Seed execution reported failure for namespace "${namespace}".`);
|
|
6565
|
+
}
|
|
6566
|
+
}
|
|
6567
|
+
},
|
|
6568
|
+
cloneData: async ({ prepared, sourceWorkspaceId, targetWorkspaceId, applicationName }) => {
|
|
6569
|
+
const state = stateOrThrow(runtimeState);
|
|
6570
|
+
await assertSourceBaselineFresh(state, prepared, sourceWorkspaceId);
|
|
6571
|
+
logger.warn("Clone mode copies TailorDB records only; IdP users and file blobs are not copied.");
|
|
6572
|
+
let operationId;
|
|
6573
|
+
try {
|
|
6574
|
+
operationId = (await state.client.cloneApplicationData({
|
|
6575
|
+
sourceWorkspaceId,
|
|
6576
|
+
sourceApplicationName: applicationName,
|
|
6577
|
+
targetWorkspaceId,
|
|
6578
|
+
targetApplicationName: applicationName
|
|
6579
|
+
})).operationId;
|
|
6580
|
+
} catch (error) {
|
|
6581
|
+
if (error instanceof ConnectError && error.code === Code.Unimplemented) throw new Error("Application data clone is not enabled for this platform. Retry with '--data seed'.", { cause: error });
|
|
6582
|
+
throw error;
|
|
6583
|
+
}
|
|
6584
|
+
if (!operationId) throw new Error("Application data clone returned no operation ID.");
|
|
6585
|
+
await waitForCloneApplicationData(state.client, {
|
|
6586
|
+
sourceWorkspaceId,
|
|
6587
|
+
targetWorkspaceId,
|
|
6588
|
+
operationId
|
|
6589
|
+
});
|
|
6590
|
+
},
|
|
6591
|
+
deployMigrations: async ({ prepared, targetWorkspaceId }) => {
|
|
6592
|
+
const state = stateOrThrow(runtimeState);
|
|
6593
|
+
await deployMigrationTestTarget({
|
|
6594
|
+
configPath: state.loaded.config.path,
|
|
6595
|
+
profile: state.options.profile,
|
|
6596
|
+
workspaceId: targetWorkspaceId,
|
|
6597
|
+
yes: true
|
|
6598
|
+
}, prepared.targetSnapshots);
|
|
6599
|
+
},
|
|
6600
|
+
runAssertion: async ({ prepared, targetWorkspaceId, assertionPath, assertionNamespace, machineUser }) => {
|
|
6601
|
+
const state = stateOrThrow(runtimeState);
|
|
6602
|
+
const namespace = resolveAssertionNamespace(prepared.pendingNamespaces, assertionNamespace);
|
|
6603
|
+
const auth = authExecutionContext(state, namespace, machineUser);
|
|
6604
|
+
const bundled = await bundleMigrationScript(assertionPath, namespace, 0, state.loaded.config.env ?? {}, path.dirname(state.loaded.config.path));
|
|
6605
|
+
const execution = await executeScript({
|
|
6606
|
+
client: state.client,
|
|
6607
|
+
workspaceId: targetWorkspaceId,
|
|
6608
|
+
name: `migration-test-assert-${namespace}.ts`,
|
|
6609
|
+
code: bundled.bundledCode,
|
|
6610
|
+
invoker: {
|
|
6611
|
+
namespace: auth.authNamespace,
|
|
6612
|
+
machineUserName: auth.machineUserName
|
|
6613
|
+
}
|
|
6614
|
+
});
|
|
6615
|
+
if (!execution.success) throw new Error(execution.error ?? `Migration assertion failed for namespace "${namespace}".`);
|
|
6616
|
+
},
|
|
6617
|
+
deleteWorkspace: async (workspaceId) => {
|
|
6618
|
+
await stateOrThrow(runtimeState).client.deleteWorkspace({ workspaceId });
|
|
6619
|
+
}
|
|
6620
|
+
};
|
|
6621
|
+
}
|
|
6622
|
+
|
|
6623
|
+
//#endregion
|
|
6624
|
+
//#region src/cli/commands/tailordb/migrate/test.ts
|
|
6625
|
+
/**
|
|
6626
|
+
* Run pending migrations against an isolated target workspace.
|
|
6627
|
+
* @param options - Migration test options
|
|
6628
|
+
* @param dependencies - Runtime operations used by the migration test
|
|
6629
|
+
* @returns Migration test result
|
|
6630
|
+
*/
|
|
6631
|
+
async function runMigrationTest(options, dependencies = createMigrationTestDependencies()) {
|
|
6632
|
+
if (options.targetWorkspaceId && !options.yes) throw new Error("--target-workspace-id requires --yes because the target may be overwritten.");
|
|
6633
|
+
if (options.keep && options.targetWorkspaceId) throw new Error("--keep applies only to automatically created workspaces; a designated target is always retained.");
|
|
6634
|
+
if (options.assertionNamespace && !options.assertionPath) throw new Error("--assert is required when --assert-namespace is provided.");
|
|
6635
|
+
const prepared = await dependencies.prepare(options);
|
|
6636
|
+
assertTargetWorkspaceDiffers(prepared.sourceWorkspaceId, options.targetWorkspaceId);
|
|
6637
|
+
if (options.assertionPath) resolveAssertionNamespace(prepared.pendingNamespaces, options.assertionNamespace);
|
|
6638
|
+
if (options.data === "clone" && options.targetWorkspaceId) {
|
|
6639
|
+
const target = assertDefined(prepared.designatedTarget, "designated clone target was not loaded during preparation");
|
|
6640
|
+
assertCloneTargetRegion(prepared.temporaryWorkspace.region, target.region);
|
|
6641
|
+
}
|
|
6642
|
+
const temporary = options.targetWorkspaceId === void 0;
|
|
6643
|
+
const workspace = temporary ? await dependencies.createWorkspace(prepared) : {
|
|
6644
|
+
id: assertDefined(options.targetWorkspaceId, "designated target workspace missing"),
|
|
6645
|
+
name: void 0
|
|
6646
|
+
};
|
|
6647
|
+
let primaryError;
|
|
6648
|
+
let failed = false;
|
|
6649
|
+
let deleted = false;
|
|
6650
|
+
try {
|
|
6651
|
+
const target = {
|
|
6652
|
+
prepared,
|
|
6653
|
+
targetWorkspaceId: workspace.id
|
|
6654
|
+
};
|
|
6655
|
+
await dependencies.deployBaseline(target);
|
|
6656
|
+
const dataTarget = {
|
|
6657
|
+
...target,
|
|
6658
|
+
sourceWorkspaceId: prepared.sourceWorkspaceId,
|
|
6659
|
+
applicationName: prepared.sourceApplicationName
|
|
6660
|
+
};
|
|
6661
|
+
if (options.data === "clone") await dependencies.cloneData(dataTarget);
|
|
6662
|
+
else await dependencies.seedData(dataTarget);
|
|
6663
|
+
await dependencies.deployMigrations(target);
|
|
6664
|
+
if (options.assertionPath) await dependencies.runAssertion({
|
|
6665
|
+
...target,
|
|
6666
|
+
assertionPath: options.assertionPath,
|
|
6667
|
+
assertionNamespace: options.assertionNamespace,
|
|
6668
|
+
machineUser: options.machineUser
|
|
6669
|
+
});
|
|
6670
|
+
} catch (error) {
|
|
6671
|
+
primaryError = error;
|
|
6672
|
+
failed = true;
|
|
6673
|
+
}
|
|
6674
|
+
if (temporary && options.keep) logger.info(`Keeping temporary workspace ${workspace.name ?? workspace.id}.`);
|
|
6675
|
+
else if (temporary) try {
|
|
6676
|
+
await dependencies.deleteWorkspace(workspace.id);
|
|
6677
|
+
deleted = true;
|
|
6678
|
+
} catch (cleanupError) {
|
|
6679
|
+
if (!failed) throw new Error(`Failed to delete temporary workspace ${workspace.id}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: cleanupError });
|
|
6680
|
+
logger.warn(`Failed to delete temporary workspace ${workspace.id}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
6681
|
+
}
|
|
6682
|
+
if (failed) throw primaryError;
|
|
6683
|
+
return {
|
|
6684
|
+
success: true,
|
|
6685
|
+
workspaceId: workspace.id,
|
|
6686
|
+
...workspace.name ? { workspaceName: workspace.name } : {},
|
|
6687
|
+
temporary,
|
|
6688
|
+
deleted,
|
|
6689
|
+
data: options.data,
|
|
6690
|
+
pendingNamespaces: prepared.pendingNamespaces
|
|
6691
|
+
};
|
|
6692
|
+
}
|
|
6693
|
+
async function migrationTest(options) {
|
|
6694
|
+
logBetaWarning("tailordb migration");
|
|
6695
|
+
const result = await runMigrationTest(options);
|
|
6696
|
+
if (options.json) {
|
|
6697
|
+
logger.out(result);
|
|
6698
|
+
return;
|
|
6699
|
+
}
|
|
6700
|
+
logger.success(`Pending migrations completed in workspace ${result.workspaceName ?? result.workspaceId}.`);
|
|
6701
|
+
}
|
|
6702
|
+
const testCommand = defineAppCommand({
|
|
6703
|
+
name: "test",
|
|
6704
|
+
description: "Test pending migrations with seed fixtures or cloned data in a temporary workspace.",
|
|
6705
|
+
notes: "The source workspace is read-only. Without --target-workspace-id, the command creates a workspace in the source workspace's region and deletes it after success or failure; pass --keep to retain it for inspection. A designated target is retained and requires --yes. Clone mode copies TailorDB records only; it does not copy IdP users or file blobs.",
|
|
6706
|
+
args: z.strictObject({
|
|
6707
|
+
...deploymentArgs,
|
|
6708
|
+
yes: arg(z.boolean().default(false), {
|
|
6709
|
+
alias: "y",
|
|
6710
|
+
description: "Acknowledge that a designated target workspace may be overwritten"
|
|
6711
|
+
}),
|
|
6712
|
+
data: arg(z.enum(["seed", "clone"]).default("seed"), { description: "Data source for the migration test (seed or clone)" }),
|
|
6713
|
+
"target-workspace-id": arg(z.uuid().optional(), { description: "Existing throwaway workspace to retain after the test (requires --yes)" }),
|
|
6714
|
+
keep: arg(z.boolean().default(false), { description: "Keep the automatically created workspace after the test" }),
|
|
6715
|
+
assert: arg(z.string().optional(), {
|
|
6716
|
+
description: "Path to a TypeScript assertion script to run after migrations",
|
|
6717
|
+
completion: {
|
|
6718
|
+
type: "file",
|
|
6719
|
+
extensions: ["ts"]
|
|
6720
|
+
}
|
|
6721
|
+
}),
|
|
6722
|
+
"assert-namespace": arg(z.string().optional(), { description: "TailorDB namespace exposed to the assertion script" }),
|
|
6723
|
+
"machine-user": arg(z.string().optional(), { description: "Machine user for seed and assertion script execution" })
|
|
6724
|
+
}),
|
|
6725
|
+
run: async (args) => {
|
|
6726
|
+
await assertWritable({ profile: args.profile });
|
|
6727
|
+
await migrationTest({
|
|
6728
|
+
configPath: args.config,
|
|
6729
|
+
workspaceId: args["workspace-id"],
|
|
6730
|
+
profile: args.profile,
|
|
6731
|
+
data: args.data,
|
|
6732
|
+
targetWorkspaceId: args["target-workspace-id"],
|
|
6733
|
+
keep: args.keep,
|
|
6734
|
+
assertionPath: args.assert,
|
|
6735
|
+
assertionNamespace: args["assert-namespace"],
|
|
6736
|
+
machineUser: args["machine-user"],
|
|
6737
|
+
yes: args.yes,
|
|
6738
|
+
json: logger.jsonMode
|
|
6739
|
+
});
|
|
6740
|
+
}
|
|
6741
|
+
});
|
|
6742
|
+
|
|
6004
6743
|
//#endregion
|
|
6005
6744
|
//#region src/cli/commands/tailordb/migrate/validate.ts
|
|
6006
6745
|
/**
|
|
6007
|
-
*
|
|
6008
|
-
* migration script
|
|
6746
|
+
* Walk the local migration history once to assert that every required
|
|
6747
|
+
* migration script exists and generated normalization logic has been
|
|
6748
|
+
* reviewed, and to collect migrations with data-loss warnings that have
|
|
6749
|
+
* neither a migrate.ts nor a recorded --no-script acknowledgment
|
|
6009
6750
|
* @param {string} migrationsDir - Migrations directory path
|
|
6010
6751
|
* @param {string} namespace - TailorDB namespace (for error messages)
|
|
6752
|
+
* @returns {UnacknowledgedWarningMigration[]} Unacknowledged warning migrations in the local history
|
|
6011
6753
|
*/
|
|
6012
|
-
function
|
|
6754
|
+
function assertMigrationScriptsReady(migrationsDir, namespace) {
|
|
6013
6755
|
const missing = [];
|
|
6756
|
+
const unreviewed = [];
|
|
6757
|
+
const unacknowledgedWarnings = [];
|
|
6014
6758
|
for (const file of getMigrationFiles(migrationsDir)) {
|
|
6015
6759
|
if (file.type !== "diff") continue;
|
|
6016
6760
|
const diff = loadDiff(file.path);
|
|
6017
|
-
|
|
6018
|
-
|
|
6761
|
+
const migrateFilePath = getMigrationFilePath(migrationsDir, file.number, "migrate");
|
|
6762
|
+
const hasScript = fs.existsSync(migrateFilePath);
|
|
6763
|
+
if (diff.requiresMigrationScript && !diff.scriptSkipped && !hasScript) missing.push(file.number);
|
|
6764
|
+
if (hasScript && fs.readFileSync(migrateFilePath, "utf8").includes("TODO(tailor-migration-review)")) unreviewed.push(file.number);
|
|
6765
|
+
if (diff.hasWarnings && !diff.scriptSkipped && !hasScript) unacknowledgedWarnings.push({
|
|
6766
|
+
migrationNumber: file.number,
|
|
6767
|
+
warnings: diff.warnings
|
|
6768
|
+
});
|
|
6019
6769
|
}
|
|
6020
6770
|
if (missing.length > 0) throw new Error(`Migration(s) ${missing.map(formatMigrationNumber).join(", ")} in namespace "${namespace}" require a migration script but have no migrate.ts. Add one with 'tailor tailordb migration script <number>', or record that no script is needed with 'tailor tailordb migration script <number> --no-script --reason "..."'.`);
|
|
6771
|
+
if (unreviewed.length > 0) throw new Error(`Migration(s) ${unreviewed.map(formatMigrationNumber).join(", ")} in namespace "${namespace}" contain generated normalization logic that still requires review in migrate.ts. Review each ${MIGRATION_REVIEW_REQUIRED_MARKER} marker, then remove the marker and its associated \`never\` annotation.`);
|
|
6772
|
+
return unacknowledgedWarnings;
|
|
6021
6773
|
}
|
|
6022
6774
|
/**
|
|
6023
6775
|
* Build per-namespace reports from the checks that completed
|
|
@@ -6025,7 +6777,7 @@ function assertRequiredMigrationScripts(migrationsDir, namespace) {
|
|
|
6025
6777
|
* @returns {NamespaceValidationReport[]} Validation reports per namespace
|
|
6026
6778
|
*/
|
|
6027
6779
|
function buildValidationReports(options) {
|
|
6028
|
-
const { targetNamespaces, migrationFileErrors, localResults, remoteResults } = options;
|
|
6780
|
+
const { targetNamespaces, migrationFileErrors, localResults, remoteResults, unacknowledgedWarnings } = options;
|
|
6029
6781
|
return targetNamespaces.map((target) => {
|
|
6030
6782
|
const fileError = migrationFileErrors.get(target.namespace);
|
|
6031
6783
|
if (fileError !== void 0) return {
|
|
@@ -6055,14 +6807,25 @@ function buildValidationReports(options) {
|
|
|
6055
6807
|
hasDrift: remote.hasDrift,
|
|
6056
6808
|
drifts: remote.drifts,
|
|
6057
6809
|
...remote.skipped ? { skipped: remote.skipped } : {},
|
|
6058
|
-
...checkpointMissingLocal ? { checkpointMissingLocal: true } : {}
|
|
6810
|
+
...checkpointMissingLocal ? { checkpointMissingLocal: true } : {},
|
|
6811
|
+
...remote.checkpointRepair ? { checkpointRepair: remote.checkpointRepair } : {}
|
|
6059
6812
|
};
|
|
6813
|
+
const candidates = unacknowledgedWarnings?.get(target.namespace);
|
|
6814
|
+
let warningAcknowledgments;
|
|
6815
|
+
if (candidates !== void 0) {
|
|
6816
|
+
const missing = candidates.filter((candidate) => candidate.migrationNumber > remote.remoteMigrationNumber);
|
|
6817
|
+
warningAcknowledgments = {
|
|
6818
|
+
valid: missing.length === 0,
|
|
6819
|
+
missing
|
|
6820
|
+
};
|
|
6821
|
+
}
|
|
6060
6822
|
return {
|
|
6061
6823
|
namespace: target.namespace,
|
|
6062
|
-
valid: !localSchema.hasDiff && !remoteSchema.hasDrift && !checkpointMissingLocal,
|
|
6824
|
+
valid: !localSchema.hasDiff && !remoteSchema.hasDrift && !checkpointMissingLocal && warningAcknowledgments?.valid !== false,
|
|
6063
6825
|
migrationFiles: { valid: true },
|
|
6064
6826
|
localSchema,
|
|
6065
|
-
remoteSchema
|
|
6827
|
+
remoteSchema,
|
|
6828
|
+
...warningAcknowledgments ? { warningAcknowledgments } : {}
|
|
6066
6829
|
};
|
|
6067
6830
|
});
|
|
6068
6831
|
}
|
|
@@ -6078,7 +6841,7 @@ async function collectValidationReports(options) {
|
|
|
6078
6841
|
const targetNamespaces = options.namespace ? namespacesWithMigrations.filter((ns) => ns.namespace === options.namespace) : namespacesWithMigrations;
|
|
6079
6842
|
if (targetNamespaces.length === 0) throw new Error(`Namespace "${options.namespace}" not found or does not have migrations configured`);
|
|
6080
6843
|
const pluginManager = plugins.length > 0 ? new PluginManager(plugins) : void 0;
|
|
6081
|
-
const { defineApplication } = await import("../application-
|
|
6844
|
+
const { defineApplication } = await import("../application-BxLLiLsr.mjs");
|
|
6082
6845
|
const application = defineApplication({
|
|
6083
6846
|
config,
|
|
6084
6847
|
pluginManager
|
|
@@ -6093,10 +6856,12 @@ async function collectValidationReports(options) {
|
|
|
6093
6856
|
const typesByNamespace = new Map(tailorDBInputs.map((input) => [input.namespace, input.types]));
|
|
6094
6857
|
const migrationFileErrors = /* @__PURE__ */ new Map();
|
|
6095
6858
|
const checkableNamespaces = [];
|
|
6859
|
+
const unacknowledgedWarnings = options.strict ? /* @__PURE__ */ new Map() : void 0;
|
|
6096
6860
|
for (const target of targetNamespaces) try {
|
|
6097
6861
|
assertValidMigrationFiles(target.migrationsDir, target.namespace);
|
|
6098
6862
|
reconstructSnapshotFromMigrations(target.migrationsDir);
|
|
6099
|
-
|
|
6863
|
+
const namespaceWarnings = assertMigrationScriptsReady(target.migrationsDir, target.namespace);
|
|
6864
|
+
unacknowledgedWarnings?.set(target.namespace, namespaceWarnings);
|
|
6100
6865
|
checkableNamespaces.push(target);
|
|
6101
6866
|
} catch (error) {
|
|
6102
6867
|
migrationFileErrors.set(target.namespace, error instanceof Error ? error.message : String(error));
|
|
@@ -6104,7 +6869,8 @@ async function collectValidationReports(options) {
|
|
|
6104
6869
|
const localReportOptions = {
|
|
6105
6870
|
targetNamespaces,
|
|
6106
6871
|
migrationFileErrors,
|
|
6107
|
-
localResults: await checkMigrationDiffs(typesByNamespace, checkableNamespaces)
|
|
6872
|
+
localResults: await checkMigrationDiffs(typesByNamespace, checkableNamespaces),
|
|
6873
|
+
...unacknowledgedWarnings ? { unacknowledgedWarnings } : {}
|
|
6108
6874
|
};
|
|
6109
6875
|
if (checkableNamespaces.length === 0) return { reports: buildValidationReports(localReportOptions) };
|
|
6110
6876
|
let remoteResults;
|
|
@@ -6143,6 +6909,7 @@ function printValidationReports(reports) {
|
|
|
6143
6909
|
else logger.log(` Local schema: ${styles.success("OK")}`);
|
|
6144
6910
|
const remote = report.remoteSchema;
|
|
6145
6911
|
if (remote?.skipped === "check_failed") logger.log(` Remote schema: ${styles.error("not checked")}`);
|
|
6912
|
+
else if (remote?.checkpointRepair) logger.log(` Remote schema: ${styles.success("OK")} (next deploy will reset the checkpoint to 0000 from ${formatMigrationNumber(remote.checkpointRepair.from)})`);
|
|
6146
6913
|
else if (remote?.checkpointMissingLocal) logger.log(` Remote schema: ${styles.error(`remote migration ${formatMigrationNumber(remote.remoteMigrationNumber)} is not in the local migration history`)}`);
|
|
6147
6914
|
else if (remote?.hasDrift) {
|
|
6148
6915
|
logger.log(` Remote schema: ${styles.error("drift detected")} (remote migration: ${formatMigrationNumber(remote.remoteMigrationNumber)})`);
|
|
@@ -6151,14 +6918,36 @@ function printValidationReports(reports) {
|
|
|
6151
6918
|
else if (remote?.skipped === "no_migration_label") logger.log(` Remote schema: ${styles.dim("skipped (deployed namespace has no migration state)")}`);
|
|
6152
6919
|
else if (remote?.skipped === "no_snapshot") logger.log(` Remote schema: ${styles.dim(`skipped (no local snapshot for remote migration ${formatMigrationNumber(remote.remoteMigrationNumber)})`)}`);
|
|
6153
6920
|
else if (remote) logger.log(` Remote schema: ${styles.success("OK")} (remote migration: ${formatMigrationNumber(remote.remoteMigrationNumber)})`);
|
|
6921
|
+
const acknowledgments = report.warningAcknowledgments;
|
|
6922
|
+
if (acknowledgments) if (acknowledgments.valid) logger.log(` Warning acknowledgments: ${styles.success("OK")}`);
|
|
6923
|
+
else {
|
|
6924
|
+
logger.log(` Warning acknowledgments: ${styles.error("missing for pending migration(s) with possible data loss")}`);
|
|
6925
|
+
for (const migration of acknowledgments.missing) {
|
|
6926
|
+
const label = formatMigrationNumber(migration.migrationNumber);
|
|
6927
|
+
for (const warning of migration.warnings) {
|
|
6928
|
+
const field = warning.fieldName ? `.${warning.fieldName}` : "";
|
|
6929
|
+
logger.log(` ${label}: ${warning.typeName}${field}: ${warning.reason}`);
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6932
|
+
}
|
|
6154
6933
|
}
|
|
6155
6934
|
logger.newline();
|
|
6156
6935
|
}
|
|
6157
|
-
function printResolutionHints(reports) {
|
|
6936
|
+
function printResolutionHints(reports, configPath) {
|
|
6158
6937
|
if (reports.some((r) => r.localSchema?.hasDiff && !r.localSchema.diff)) logger.info("Run 'tailor tailordb migration generate' to create the initial snapshot.");
|
|
6159
6938
|
if (reports.some((r) => r.localSchema?.diff)) logger.info("Run 'tailor tailordb migration generate' to create migration files.");
|
|
6160
6939
|
if (reports.some((r) => r.remoteSchema?.checkpointMissingLocal)) logger.info("The remote migration checkpoint is ahead of the local history. Pull the latest migration files, or run 'tailor tailordb migration status' to compare.");
|
|
6161
6940
|
if (reports.some((r) => r.remoteSchema?.hasDrift)) logRemoteDriftGuidance();
|
|
6941
|
+
const missingAcknowledgments = reports.filter((r) => r.warningAcknowledgments && !r.warningAcknowledgments.valid);
|
|
6942
|
+
if (missingAcknowledgments.length > 0) {
|
|
6943
|
+
logger.info("For each migration listed, either add a data migration script with 'tailor tailordb migration script <number>' or record why none is needed:");
|
|
6944
|
+
for (const report of missingAcknowledgments) for (const migration of report.warningAcknowledgments?.missing ?? []) logger.info(` ${formatMigrationScriptCommand({
|
|
6945
|
+
migrationNumber: migration.migrationNumber,
|
|
6946
|
+
namespace: report.namespace,
|
|
6947
|
+
configPath,
|
|
6948
|
+
noScript: true
|
|
6949
|
+
})}`, { mode: "plain" });
|
|
6950
|
+
}
|
|
6162
6951
|
}
|
|
6163
6952
|
/**
|
|
6164
6953
|
* Validate migration files and schema state without deploying
|
|
@@ -6173,20 +6962,21 @@ async function validate(options) {
|
|
|
6173
6962
|
else {
|
|
6174
6963
|
printValidationReports(reports);
|
|
6175
6964
|
if (invalidCount === 0 && !("remoteError" in collected)) logger.success("All migration validation checks passed.");
|
|
6176
|
-
else printResolutionHints(reports);
|
|
6965
|
+
else printResolutionHints(reports, options.configPath);
|
|
6177
6966
|
}
|
|
6178
6967
|
if ("remoteError" in collected) throw collected.remoteError;
|
|
6179
6968
|
if (invalidCount > 0) throw new Error(`Migration validation failed for ${invalidCount} namespace(s)`);
|
|
6180
6969
|
}
|
|
6181
6970
|
const validateCommand = defineAppCommand({
|
|
6182
6971
|
name: "validate",
|
|
6183
|
-
description: "Validate the full migration history and
|
|
6972
|
+
description: "Validate the full migration history, unreviewed generated migration scripts, and schema drift (local types vs. migration snapshot, remote schema vs. migration checkpoint) without deploying. This includes the migration and schema-drift checks used by 'deploy' and exits with a non-zero code when issues are found.",
|
|
6184
6973
|
args: z.strictObject({
|
|
6185
6974
|
...deploymentArgs,
|
|
6186
6975
|
namespace: arg(z.string().optional(), {
|
|
6187
6976
|
alias: "n",
|
|
6188
6977
|
description: "Target TailorDB namespace (validates all namespaces if not specified)"
|
|
6189
|
-
})
|
|
6978
|
+
}),
|
|
6979
|
+
strict: arg(z.boolean().default(false), { description: "Also fail when a pending migration can drop data without an acknowledgment" })
|
|
6190
6980
|
}),
|
|
6191
6981
|
run: async (args) => {
|
|
6192
6982
|
await validate({
|
|
@@ -6194,7 +6984,8 @@ const validateCommand = defineAppCommand({
|
|
|
6194
6984
|
namespace: args.namespace,
|
|
6195
6985
|
workspaceId: args["workspace-id"],
|
|
6196
6986
|
profile: args.profile,
|
|
6197
|
-
json: logger.jsonMode
|
|
6987
|
+
json: logger.jsonMode,
|
|
6988
|
+
strict: args.strict
|
|
6198
6989
|
});
|
|
6199
6990
|
}
|
|
6200
6991
|
});
|
|
@@ -6206,10 +6997,12 @@ const validateCommand = defineAppCommand({
|
|
|
6206
6997
|
*
|
|
6207
6998
|
* Subcommands:
|
|
6208
6999
|
* - generate: Generate migration files from schema differences
|
|
7000
|
+
* - rebaseline: Collapse the full migration history into a new baseline
|
|
6209
7001
|
* - script: Add a migrate.ts template to an existing migration
|
|
6210
7002
|
* - set: Set migration checkpoint to a specific number
|
|
6211
7003
|
* - status: Show migration status for TailorDB namespaces
|
|
6212
7004
|
* - sync: Sync remote TailorDB schema to a specific migration snapshot
|
|
7005
|
+
* - test: Test pending migrations in an isolated workspace
|
|
6213
7006
|
* - validate: Validate migration files and detect schema drift without deploying
|
|
6214
7007
|
*/
|
|
6215
7008
|
const migrationCommand = defineCommand({
|
|
@@ -6217,10 +7010,12 @@ const migrationCommand = defineCommand({
|
|
|
6217
7010
|
description: "Manage TailorDB schema migrations.",
|
|
6218
7011
|
subCommands: {
|
|
6219
7012
|
generate: generateCommand$1,
|
|
7013
|
+
rebaseline: rebaselineCommand,
|
|
6220
7014
|
script: scriptCommand,
|
|
6221
7015
|
set: setCommand,
|
|
6222
7016
|
status: statusCommand,
|
|
6223
7017
|
sync: syncCommand,
|
|
7018
|
+
test: testCommand,
|
|
6224
7019
|
validate: validateCommand
|
|
6225
7020
|
}
|
|
6226
7021
|
});
|
|
@@ -6256,7 +7051,7 @@ const upgradeCommand = defineAppCommand({
|
|
|
6256
7051
|
run: async (args) => {
|
|
6257
7052
|
const { initTelemetry } = await import("../telemetry-CkbkeJxl.mjs");
|
|
6258
7053
|
await initTelemetry();
|
|
6259
|
-
const { upgrade } = await import("../service-
|
|
7054
|
+
const { upgrade } = await import("../service-CCwl3Avt.mjs");
|
|
6260
7055
|
await upgrade({
|
|
6261
7056
|
from: args.from,
|
|
6262
7057
|
dryRun: args["dry-run"],
|
|
@@ -6763,7 +7558,7 @@ runMain(mainCommand, {
|
|
|
6763
7558
|
if (isVerbose() && error.stack) logger.debug(`\nStack trace:\n${error.stack}`);
|
|
6764
7559
|
} else logger.error(`Unknown error: ${error}`);
|
|
6765
7560
|
if (!isCLIError(error) && (!(error instanceof Error) || error instanceof TypeError || error instanceof RangeError)) {
|
|
6766
|
-
const { reportCrash } = await import("../crashreport-
|
|
7561
|
+
const { reportCrash } = await import("../crashreport-CINZ5dVN.mjs");
|
|
6767
7562
|
await reportCrash(error, "handledError");
|
|
6768
7563
|
}
|
|
6769
7564
|
}
|