furniture-3d-system-Wu/furniture-3d-system/scripts/master-data-required-migrations.js

2018 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('node:fs')
const path = require('node:path')
const ROOT_DIR = path.resolve(__dirname, '..')
const STANDARD_PRISMA_MIGRATION_DIR_PATTERN = /^\d{14}_[A-Za-z0-9_]+$/
const LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS = new Set([
'20251213_performance_optimization',
'add_packaging_plan',
])
const TARGET_ONLY_READINESS_ASSURANCES = new Set([
'external-readonly-contract',
'database-guard-summary',
'foundation-quality-summary',
])
const MIGRATION_GUARD_CHECK_TYPES = new Set([
'trigger',
'check-constraint',
'unique-index',
'partial-unique-index',
])
function hasExplicitReadinessChecks(migration) {
return Boolean(
migration.columnCheck ||
(Array.isArray(migration.columnDefaultChecks) && migration.columnDefaultChecks.length > 0) ||
(Array.isArray(migration.guardChecks) && migration.guardChecks.length > 0) ||
(Array.isArray(migration.relationColumnChecks) && migration.relationColumnChecks.length > 0) ||
(Array.isArray(migration.dataChecks) && migration.dataChecks.length > 0),
)
}
const REQUIRED_MIGRATIONS = [
{
targetRelation: 'external.product_skus_v1',
targetRelations: ['external.product_skus_v1', 'external.intake_products_v1'],
path: 'prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql',
},
{
targetRelation: '"IntakeNonIntegratedSkuItem"',
path: 'prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql',
guardChecks: [
{
type: 'trigger',
name: 'IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items',
tableName: 'IntakeNonIntegratedProduct',
},
],
dataChecks: [
{
name: 'IntakeNonIntegratedSkuItem_payload_missing_rows_zero',
expectedValue: 0,
sql: `
WITH source_sku_items AS (
SELECT
p."id" AS product_id,
item.ordinality::integer AS sort_order,
COALESCE(NULLIF(btrim(item.sku_item ->> 'id'), ''), 'sku-' || item.ordinality::text) AS raw_sku_item_id
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
),
deduped_sku_items AS (
SELECT
source_sku_items.*,
CASE
WHEN COUNT(*) OVER (PARTITION BY product_id, raw_sku_item_id) = 1
THEN raw_sku_item_id
ELSE raw_sku_item_id || '-' || ROW_NUMBER() OVER (
PARTITION BY product_id, raw_sku_item_id
ORDER BY sort_order
)::text
END AS sku_item_id
FROM source_sku_items
)
SELECT COUNT(*)::integer AS row_count
FROM deduped_sku_items source
LEFT JOIN "IntakeNonIntegratedSkuItem" sku
ON sku."productId" = source.product_id
AND sku."skuItemId" = source.sku_item_id
WHERE sku."id" IS NULL
`,
},
],
repairSql: `
WITH source_sku_items AS (
SELECT
p."id" AS product_id,
item.sku_item,
item.ordinality::integer AS sort_order,
COALESCE(NULLIF(btrim(item.sku_item ->> 'id'), ''), 'sku-' || item.ordinality::text) AS raw_sku_item_id
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
),
deduped_sku_items AS (
SELECT
source_sku_items.*,
CASE
WHEN COUNT(*) OVER (PARTITION BY product_id, raw_sku_item_id) = 1
THEN raw_sku_item_id
ELSE raw_sku_item_id || '-' || ROW_NUMBER() OVER (
PARTITION BY product_id, raw_sku_item_id
ORDER BY sort_order
)::text
END AS sku_item_id
FROM source_sku_items
),
normalized_sku_items AS (
SELECT
product_id,
sku_item,
sort_order,
sku_item_id,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'skuCode', ''))), '') AS normalized_sku_code,
NULLIF(upper(btrim(COALESCE(sku_item ->> 'barcode', ''))), '') AS normalized_barcode,
array_remove(
ARRAY[
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'skuCode', '')), '') IS NULL
THEN 'sku_code'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'specName', '')), '') IS NULL
THEN 'spec_name'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'model', '')), '') IS NULL
THEN 'model'
END
]::text[],
NULL
) AS missing_required_fields
FROM deduped_sku_items
),
quality_sku_items AS (
SELECT
normalized_sku_items.*,
COUNT(*) FILTER (WHERE normalized_sku_code IS NOT NULL) OVER (
PARTITION BY normalized_sku_code
) AS sku_code_occurrences,
COUNT(*) FILTER (WHERE normalized_barcode IS NOT NULL) OVER (
PARTITION BY normalized_barcode
) AS barcode_occurrences
FROM normalized_sku_items
)
INSERT INTO "IntakeNonIntegratedSkuItem" (
"id",
"productId",
"skuItemId",
"sortOrder",
"skuSequence",
"batchNo",
"packageGroupId",
"image",
"imageName",
"model",
"skuCode",
"specName",
"barcode",
"size",
"color",
"material",
"boardMaterialType",
"versionNo",
"attributeCode",
"drawingId",
"installMethod",
"executeStandard",
"nationalStandardCompliant",
"manufacturer",
"manufacturerCode",
"salesStatus",
"tagMaterial",
"model3dDataUrl",
"source",
"sourceTable",
"sourceSkuId",
"contentLocked",
"dataQualityStatus",
"missingRequiredFields",
"createdAt",
"updatedAt"
)
SELECT
product_id || ':' || sku_item_id AS id,
product_id,
sku_item_id,
sort_order,
CASE
WHEN COALESCE(sku_item ->> 'skuSequence', '') ~ '^-?[0-9]+$'
THEN (sku_item ->> 'skuSequence')::integer
ELSE 0
END,
CASE
WHEN COALESCE(sku_item ->> 'batchNo', '') ~ '^[0-9]+$'
THEN GREATEST(1, (sku_item ->> 'batchNo')::integer)
ELSE 1
END,
COALESCE(sku_item ->> 'packageGroupId', ''),
NULLIF(sku_item ->> 'image', ''),
COALESCE(sku_item ->> 'imageName', ''),
COALESCE(sku_item ->> 'model', ''),
COALESCE(sku_item ->> 'skuCode', ''),
COALESCE(sku_item ->> 'specName', ''),
COALESCE(sku_item ->> 'barcode', ''),
COALESCE(sku_item ->> 'size', ''),
COALESCE(sku_item ->> 'color', ''),
COALESCE(sku_item ->> 'material', ''),
COALESCE(sku_item ->> 'boardMaterialType', ''),
COALESCE(NULLIF(sku_item ->> 'versionNo', ''), 'V01'),
COALESCE(sku_item ->> 'attributeCode', ''),
COALESCE(sku_item ->> 'drawingId', ''),
COALESCE(sku_item ->> 'installMethod', ''),
COALESCE(sku_item ->> 'executeStandard', ''),
COALESCE(sku_item ->> 'nationalStandardCompliant', ''),
COALESCE(sku_item ->> 'manufacturer', ''),
COALESCE(sku_item ->> 'manufacturerCode', ''),
COALESCE(sku_item ->> 'salesStatus', ''),
COALESCE(sku_item ->> 'tagMaterial', ''),
NULLIF(sku_item ->> 'model3dDataUrl', ''),
COALESCE(sku_item ->> 'source', ''),
COALESCE(sku_item ->> 'sourceTable', ''),
COALESCE(sku_item ->> 'sourceSkuId', ''),
LOWER(COALESCE(sku_item ->> 'contentLocked', '')) IN ('true', '1', 'yes', 'on'),
CASE
WHEN sku_code_occurrences > 1 OR barcode_occurrences > 1
THEN 'CONFLICT'::"IntakeNonIntegratedSkuDataQualityStatus"
WHEN cardinality(missing_required_fields) = 0
THEN 'READY'::"IntakeNonIntegratedSkuDataQualityStatus"
ELSE 'INCOMPLETE'::"IntakeNonIntegratedSkuDataQualityStatus"
END,
missing_required_fields,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM quality_sku_items
ON CONFLICT ("productId", "skuItemId") DO NOTHING;
CREATE OR REPLACE FUNCTION "cleanup_intake_non_integrated_sku_items_on_soft_delete"()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM "IntakeNonIntegratedSkuItem"
WHERE "productId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS "IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items"
ON "IntakeNonIntegratedProduct";
CREATE TRIGGER "IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items"
AFTER UPDATE OF "deletedAt" ON "IntakeNonIntegratedProduct"
FOR EACH ROW
WHEN (OLD."deletedAt" IS NULL AND NEW."deletedAt" IS NOT NULL)
EXECUTE FUNCTION "cleanup_intake_non_integrated_sku_items_on_soft_delete"();
`,
},
{
targetRelation: '"ProductSkuAssetProfile"',
targetRelations: ['"ProductSkuAssetProfile"', '"ProductSKU"'],
path: 'prisma/migrations/20260530103000_add_product_sku_asset_profile/migration.sql',
columnCheck: {
tableName: 'ProductSKU',
columnName: 'name',
},
dataChecks: [
{
name: 'ProductSKU_name_backfill_missing_zero',
expectedValue: 0,
sql: `
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL
`,
},
{
name: 'ProductSkuAssetProfile_asset_snapshot_missing_rows_zero',
expectedValue: 0,
sql: `
WITH sku_source AS (
SELECT sku."id" AS sku_id
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object'
)
SELECT COUNT(*)::integer AS row_count
FROM sku_source source
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = source.sku_id
WHERE profile."id" IS NULL
`,
},
],
repairSql: `
UPDATE "ProductSKU" sku
SET "name" = COALESCE(
NULLIF(sku."attributes"::jsonb ->> 'skuName', ''),
NULLIF(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specName', ''),
sku."skuCode"
)
FROM "Product" product
WHERE product."id" = sku."productId"
AND sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL;
WITH sku_source AS (
SELECT
sku."id" AS sku_id,
sku."sourceAssetVariantId",
sku."sourceAssetVariantIds",
sku."attributes"::jsonb -> 'assetSkuSnapshot' AS asset_snapshot,
sku."updatedAt"
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object'
)
INSERT INTO "ProductSkuAssetProfile" (
"id",
"skuId",
"primarySourceAssetVariantId",
"sourceAssetVariantIds",
"sourceAssetFamilyId",
"sourceAssetSeriesId",
"model",
"modelName",
"specCode",
"specName",
"barcode",
"size",
"color",
"material",
"imageUrl",
"sourceStatus",
"syncedAt",
"lockedFields",
"lockedReasons",
"sourceVariants",
"createdAt",
"updatedAt"
)
SELECT
sku_id,
sku_id,
COALESCE(NULLIF(asset_snapshot ->> 'sourceAssetVariantId', ''), "sourceAssetVariantId"),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'sourceAssetVariantIds') = 'array' THEN ARRAY(
SELECT jsonb_array_elements_text(asset_snapshot -> 'sourceAssetVariantIds')
)
ELSE COALESCE("sourceAssetVariantIds", ARRAY[]::TEXT[])
END,
NULLIF(asset_snapshot ->> 'sourceAssetFamilyId', ''),
NULLIF(asset_snapshot ->> 'sourceAssetSeriesId', ''),
NULLIF(asset_snapshot ->> 'model', ''),
NULLIF(asset_snapshot ->> 'modelName', ''),
NULLIF(asset_snapshot ->> 'specCode', ''),
NULLIF(asset_snapshot ->> 'specName', ''),
NULLIF(asset_snapshot ->> 'barcode', ''),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'size') = 'object' THEN asset_snapshot -> 'size'
ELSE '{}'::jsonb
END,
NULLIF(asset_snapshot ->> 'color', ''),
NULLIF(asset_snapshot ->> 'material', ''),
NULLIF(asset_snapshot ->> 'imageUrl', ''),
CASE asset_snapshot ->> 'sourceStatus'
WHEN 'DELETED' THEN 'DELETED'::"ProductSkuAssetSourceStatus"
WHEN 'MISSING' THEN 'MISSING'::"ProductSkuAssetSourceStatus"
ELSE 'ACTIVE'::"ProductSkuAssetSourceStatus"
END,
CASE
WHEN COALESCE(asset_snapshot ->> 'syncedAt', '') ~ '^\\d{4}-\\d{2}-\\d{2}T'
THEN (asset_snapshot ->> 'syncedAt')::timestamp(3)
ELSE "updatedAt"
END,
COALESCE(
ARRAY(
SELECT jsonb_array_elements_text(asset_snapshot -> 'lockedFields')
WHERE jsonb_typeof(asset_snapshot -> 'lockedFields') = 'array'
),
ARRAY[]::TEXT[]
),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'lockedReasons') = 'object' THEN asset_snapshot -> 'lockedReasons'
ELSE '{}'::jsonb
END,
CASE
WHEN jsonb_typeof(asset_snapshot -> 'sourceVariants') = 'array' THEN asset_snapshot -> 'sourceVariants'
ELSE '[]'::jsonb
END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sku_source
ON CONFLICT ("skuId") DO NOTHING;
`,
},
{
targetRelation: '"PreSkuBasicInfo"',
path: 'prisma/migrations/20260530130000_add_pre_sku_basic_info/migration.sql',
dataChecks: [
{
name: 'PreSkuBasicInfo_display_summary_missing_rows_zero',
expectedValue: 0,
sql: `
WITH pre_sku_source AS (
SELECT pre_sku."id"
FROM "PreSku" pre_sku
WHERE (
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'size', pre_sku."familySelection"::jsonb ->> 'size', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'color', pre_sku."familySelection"::jsonb ->> 'color', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'material', pre_sku."familySelection"::jsonb ->> 'material', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'versionNo', pre_sku."familySelection"::jsonb ->> 'versionNo', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'setCount', pre_sku."familySelection"::jsonb ->> 'setCount', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'attributeCode', pre_sku."familySelection"::jsonb ->> 'attributeCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'drawingId', pre_sku."familySelection"::jsonb ->> 'drawingId', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'installMethod', pre_sku."familySelection"::jsonb ->> 'installMethod', '')), '') IS NOT NULL
)
)
SELECT COUNT(*)::integer AS row_count
FROM pre_sku_source source
LEFT JOIN "PreSkuBasicInfo" info ON info."preSkuId" = source."id"
WHERE info."id" IS NULL
`,
},
],
repairSql: `
INSERT INTO "PreSkuBasicInfo" (
"id",
"preSkuId",
"model",
"formalSkuCode",
"barcode",
"size",
"color",
"material",
"specName",
"versionNo",
"setCount",
"attributeCode",
"drawingId",
"installMethod",
"manualUpdatedBy",
"manualUpdatedByName",
"manualUpdatedAt",
"source",
"createdAt",
"updatedAt"
)
SELECT
pre_sku."id",
pre_sku."id",
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'size', pre_sku."familySelection"::jsonb ->> 'size', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'color', pre_sku."familySelection"::jsonb ->> 'color', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'material', pre_sku."familySelection"::jsonb ->> 'material', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'versionNo', pre_sku."familySelection"::jsonb ->> 'versionNo', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'setCount', pre_sku."familySelection"::jsonb ->> 'setCount', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'attributeCode', pre_sku."familySelection"::jsonb ->> 'attributeCode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'drawingId', pre_sku."familySelection"::jsonb ->> 'drawingId', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'installMethod', pre_sku."familySelection"::jsonb ->> 'installMethod', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedBy', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedByName', '')), ''),
CASE
WHEN COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedAt', '') ~ '^\\d{4}-\\d{2}-\\d{2}T'
THEN (pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedAt')::timestamp(3)
ELSE NULL
END,
CASE
WHEN pre_sku."displaySummary"::jsonb ? 'basicInfoManualUpdatedAt' THEN 'MANUAL'
ELSE 'DRAFT_GROUP'
END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "PreSku" pre_sku
WHERE (
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'size', pre_sku."familySelection"::jsonb ->> 'size', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'color', pre_sku."familySelection"::jsonb ->> 'color', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'material', pre_sku."familySelection"::jsonb ->> 'material', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'versionNo', pre_sku."familySelection"::jsonb ->> 'versionNo', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'setCount', pre_sku."familySelection"::jsonb ->> 'setCount', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'attributeCode', pre_sku."familySelection"::jsonb ->> 'attributeCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'drawingId', pre_sku."familySelection"::jsonb ->> 'drawingId', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'installMethod', pre_sku."familySelection"::jsonb ->> 'installMethod', '')), '') IS NOT NULL
)
ON CONFLICT ("preSkuId") DO NOTHING;
`,
},
{
targetRelation: '"ProductSkuQualityInspection"',
path: 'prisma/migrations/20260530143000_add_sku_quality_inspections/migration.sql',
columnCheck: {
tableName: 'ProductSkuQualityInspection',
columnName: 'recordKey',
},
},
{
targetRelation: 'external.product_skus_v1',
path: 'prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'external-readonly-contract',
},
{
targetRelation: '"IntakeNonIntegratedProductProfile"',
path: 'prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql',
dataChecks: [
{
name: 'IntakeNonIntegratedProductProfile_missing_rows_zero',
expectedValue: 0,
sql: `
SELECT COUNT(*)::integer AS row_count
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL
AND profile."id" IS NULL
`,
},
],
repairSql: `
INSERT INTO "IntakeNonIntegratedProductProfile" (
"id",
"productId",
"productName",
"productImageUrl",
"productCategory",
"salesCategory",
"productYear",
"productStatus",
"productDevelopmentKind",
"goodsType",
"materialType",
"productionType",
"size",
"color",
"tagMaterial",
"priceBand",
"demandFlowNo",
"processTemplate",
"productPlanning",
"brandPlanning",
"demandDepartment",
"spuOwner",
"operationsOwner",
"purchaseOwner",
"visualDirector",
"visualPlanning",
"structureDesigner",
"source",
"syncedAt",
"createdAt",
"updatedAt"
)
SELECT
'inip_' || product."id",
product."id",
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productName'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'previewImageName'), ''),
product."spu"
),
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'previewImage'), '')
),
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'productCategory'), ''),
''
),
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'salesCategory'), ''),
''
),
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'year'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'year'), ''),
''
),
COALESCE(
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''),
NULLIF(btrim(product."payload"::jsonb ->> 'projectStatus'), ''),
''
),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productDevelopmentKind'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'goodsType'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'materialType'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productionType'), ''), ''),
COALESCE(
NULLIF(btrim(product."payload"::jsonb ->> 'size'), ''),
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'size'), ''),
''
),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'color'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'tagMaterial'), ''), ''),
COALESCE(
NULLIF(btrim(product."payload"::jsonb ->> 'priceBand'), ''),
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''),
''
),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandFlowNo'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'processTemplate'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productPlanning'), ''),
NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'brandPlanning'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandDepartment'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'spuOwner'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'operationsOwner'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(product."purchaser"), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualDirector'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualPlanning'), ''), ''),
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), ''),
'MIGRATED_FROM_PAYLOAD',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "IntakeNonIntegratedProduct" product
WHERE product."deletedAt" IS NULL
ON CONFLICT ("productId") DO NOTHING;
`,
},
{
targetRelation: '"ProductPatentRecord"',
path: 'prisma/migrations/20260530162000_add_product_patent_records/migration.sql',
},
{
targetRelation: '"IntakeNonIntegratedProductProfile"',
path: 'prisma/migrations/20260530172000_add_stock_source_fields_to_intake_product_profile/migration.sql',
columnCheck: {
tableName: 'IntakeNonIntegratedProductProfile',
columnName: 'sourceSpu',
},
dataChecks: [
{
name: 'IntakeNonIntegratedProductProfile_stock_source_payload_diff_zero',
expectedValue: 0,
sql: `
SELECT COUNT(*)::integer AS row_count
FROM "IntakeNonIntegratedProductProfile" profile
JOIN "IntakeNonIntegratedProduct" product ON product."id" = profile."productId"
WHERE product."deletedAt" IS NULL
AND (
COALESCE(profile."sourceSpu", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), '')
OR COALESCE(profile."sourceSkuId", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), '')
OR COALESCE(profile."sourceCategory", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), '')
OR COALESCE(profile."sourceItemType", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), '')
OR COALESCE(profile."sourceSalesStatus", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '')
OR COALESCE(profile."sourceEnabled", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), '')
OR COALESCE(profile."sourceStockDisabled", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), '')
OR profile."stockSkuCount" IS DISTINCT FROM CASE
WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array'
THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems')
ELSE 0
END
OR profile."stockHasOnSaleSku" IS DISTINCT FROM (
COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售'
)
)
`,
},
],
repairSql: `
UPDATE "IntakeNonIntegratedProductProfile" profile
SET
"sourceSpu" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), ''),
"sourceSkuId" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), ''),
"sourceCategory" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), ''),
"sourceItemType" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), ''),
"sourceSalesStatus" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), ''),
"sourceEnabled" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), ''),
"sourceStockDisabled" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), ''),
"stockSkuCount" = CASE
WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array'
THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems')
ELSE 0
END,
"stockHasOnSaleSku" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售'
FROM "IntakeNonIntegratedProduct" product
WHERE product."id" = profile."productId"
AND product."deletedAt" IS NULL;
`,
},
{
targetRelation: '"IntakeReferenceLibraryItem"',
path: 'prisma/migrations/20260530190000_add_intake_reference_library/migration.sql',
},
{
targetRelation: 'external.intake_sku_items_v1',
path: 'prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'external-readonly-contract',
},
{
targetRelation: '"SpuNumberCounter"',
path: 'prisma/migrations/20260531113000_add_spu_number_counter/migration.sql',
dataChecks: [
{
name: 'SpuNumberCounter_active_rule_missing_rows_zero',
expectedValue: 0,
sql: `
SELECT COUNT(*)::integer AS row_count
FROM "IntakeReferenceLibraryItem" row
LEFT JOIN "SpuNumberCounter" counter ON counter."productCategory" = row."name"
WHERE row."libraryType" = 'SPU_RULE'
AND row."isActive" = true
AND NULLIF(btrim(row."name"), '') IS NOT NULL
AND NULLIF(btrim(row."code"), '') IS NOT NULL
AND counter."id" IS NULL
`,
},
],
repairSql: `
INSERT INTO "SpuNumberCounter" (
"id",
"productCategory",
"prefix",
"lastUsedIndex",
"sourceLibraryItemId",
"createdBy",
"updatedBy",
"createdAt",
"updatedAt"
)
SELECT
'spu-counter-' || regexp_replace(lower(row."itemKey"), '[^a-z0-9_-]+', '-', 'g') AS "id",
row."name" AS "productCategory",
upper(row."code") AS "prefix",
GREATEST(
COALESCE(NULLIF(row."payload"::jsonb -> 'rule' ->> 'spuIndex', '')::integer, 0),
COALESCE(used.max_used_index, 0)
) AS "lastUsedIndex",
row."id" AS "sourceLibraryItemId",
row."createdBy",
row."updatedBy",
row."createdAt",
row."updatedAt"
FROM "IntakeReferenceLibraryItem" row
LEFT JOIN LATERAL (
SELECT MAX((substring(product."spuBase" from ('^' || upper(row."code") || '([0-9]{4})$')))::integer) AS max_used_index
FROM "IntakeNonIntegratedProduct" product
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(row."code"), '') IS NOT NULL
AND product."spuBase" ~ ('^' || upper(row."code") || '[0-9]{4}$')
) used ON true
WHERE row."libraryType" = 'SPU_RULE'
AND row."isActive" = true
AND NULLIF(btrim(row."name"), '') IS NOT NULL
AND NULLIF(btrim(row."code"), '') IS NOT NULL
ON CONFLICT ("productCategory") DO UPDATE SET
"prefix" = EXCLUDED."prefix",
"lastUsedIndex" = GREATEST("SpuNumberCounter"."lastUsedIndex", EXCLUDED."lastUsedIndex"),
"sourceLibraryItemId" = EXCLUDED."sourceLibraryItemId",
"updatedBy" = EXCLUDED."updatedBy",
"updatedAt" = CURRENT_TIMESTAMP;
`,
},
{
targetRelation: '"IntakeNonIntegratedSkuItem"',
path: 'prisma/migrations/20260531143000_add_intake_master_data_check_constraints/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"SpuNumberCounter"',
path: 'prisma/migrations/20260531153000_add_spu_number_counter_check_constraints/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"IntakeReferenceLibraryItem"',
path: 'prisma/migrations/20260531162000_add_spu_rule_product_category_unique_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: 'external.intake_products_v1',
path: 'prisma/migrations/20260531170000_tighten_external_intake_product_people_sources/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'external-readonly-contract',
},
{
targetRelation: '"ProductSkuAssetProfile"',
path: 'prisma/migrations/20260531173000_add_product_sku_asset_profile_barcode_unique_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260531181000_add_product_sku_active_code_marker_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductSkuAssetProfile"',
path: 'prisma/migrations/20260531190000_add_product_sku_asset_profile_sku_deleted_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'foundation-quality-summary',
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260531203000_normalize_core_code_identity_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductSkuAssetProfile"',
path: 'prisma/migrations/20260531213000_add_product_sku_asset_profile_identity_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductPatentRecord"',
path: 'prisma/migrations/20260531223000_add_product_patent_record_identity_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"IntakeReferenceLibraryItem"',
path: 'prisma/migrations/20260531233000_add_intake_reference_library_identity_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"SpuNumberCounter"',
path: 'prisma/migrations/20260531234000_add_spu_number_counter_identity_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"PreSkuBasicInfo"',
path: 'prisma/migrations/20260531235000_add_pre_sku_basic_info_extended_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"AssetSkuSyncEvent"',
path: 'prisma/migrations/20260601000000_add_asset_sku_sync_event_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductSkuAssetProfile"',
path: 'prisma/migrations/20260601001000_add_product_sku_asset_profile_source_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"PreSkuPackagingPlan"',
path: 'prisma/migrations/20260601002000_add_packaging_warehouse_foundation_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ApprovalWorkflow"',
path: 'prisma/migrations/20260601003000_add_approval_workflow_foundation_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"User"',
path: 'prisma/migrations/20260601004000_add_people_org_foundation_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"PlanningWorkspace"',
path: 'prisma/migrations/20260601005000_add_planning_foundation_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductCollaborator"',
path: 'prisma/migrations/20260601006000_add_share_upload_collaboration_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"Panel"',
path: 'prisma/migrations/20260601007000_add_legacy_cad_structure_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductCategory"',
path: 'prisma/migrations/20260601008000_add_reference_option_and_mapping_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"SystemConfig"',
path: 'prisma/migrations/20260601009000_add_config_audit_permission_guards/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: 'external.intake_sku_items_v1',
path: 'prisma/migrations/20260601010000_add_external_intake_stable_business_keys/migration.sql',
alwaysApply: true,
relationColumnChecks: [
{
relationName: 'external.intake_products_v1',
columnNames: ['product_business_key'],
},
{
relationName: 'external.intake_products_ready_v1',
columnNames: ['product_business_key'],
},
{
relationName: 'external.intake_sku_items_v1',
columnNames: ['product_business_key', 'sku_business_key'],
},
{
relationName: 'external.intake_sku_items_ready_v1',
columnNames: ['product_business_key', 'sku_business_key'],
},
],
},
{
targetRelation: 'external.product_skus_v1',
path: 'prisma/migrations/20260601011000_add_external_product_sku_stable_business_keys/migration.sql',
alwaysApply: true,
relationColumnChecks: [
{
relationName: 'external.product_skus_v1',
columnNames: ['product_business_key', 'sku_business_key'],
},
],
},
{
targetRelation: 'external.product_assets_v1',
path: 'prisma/migrations/20260601012000_add_external_product_asset_bom_business_keys/migration.sql',
alwaysApply: true,
relationColumnChecks: [
{
relationName: 'external.product_assets_v1',
columnNames: ['product_business_key', 'sku_business_key'],
},
{
relationName: 'external.product_bom_v1',
columnNames: ['product_business_key', 'sku_business_key'],
},
],
},
{
targetRelation: '"IntakeReferenceLibraryItem"',
path: 'prisma/migrations/20260601013000_add_spu_rule_normalized_category_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"IntakeReferenceLibraryItem"',
path: 'prisma/migrations/20260601014000_add_reference_library_code_required_guard/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'database-guard-summary',
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260601015000_add_product_sku_soft_delete_identity_fields/migration.sql',
alwaysApply: true,
columnCheck: {
tableName: 'ProductSKU',
columnName: 'originalSkuCode',
},
guardChecks: [
{
type: 'check-constraint',
name: 'ProductSKU_soft_delete_identity_valid',
tableName: 'ProductSKU',
},
{
type: 'check-constraint',
name: 'ProductSKU_no_soft_delete_lifecycle_attributes',
tableName: 'ProductSKU',
},
],
repairSql: `
UPDATE "ProductSKU"
SET
"originalSkuCode" = upper(btrim(
COALESCE(
NULLIF(attributes->>'_originalSkuCode', ''),
CASE
WHEN strpos("skuCode", '__deleted__') > 0
THEN substring("skuCode" from 1 for strpos("skuCode", '__deleted__') - 1)
ELSE "skuCode"
END
)
)),
"deletedCodeAt" = COALESCE(
"deletedCodeAt",
CASE
WHEN NULLIF(attributes->>'_deletedCodeAt', '') ~ '^\\d{4}-\\d{2}-\\d{2}T'
THEN (attributes->>'_deletedCodeAt')::timestamp(3)
ELSE "deletedAt"
END
),
"deletedByProductId" = COALESCE("deletedByProductId", NULLIF(btrim(attributes->>'_deletedByProductId'), '')),
"productDeletedAt" = COALESCE(
"productDeletedAt",
CASE
WHEN NULLIF(attributes->>'_productDeletedAt', '') ~ '^\\d{4}-\\d{2}-\\d{2}T'
THEN (attributes->>'_productDeletedAt')::timestamp(3)
ELSE NULL
END
)
WHERE "deletedAt" IS NOT NULL;
UPDATE "ProductSKU"
SET attributes = attributes
- '_originalSkuCode'
- '_deletedCodeAt'
- '_deletedByProductId'
- '_productDeletedAt'
WHERE attributes ? '_originalSkuCode'
OR attributes ? '_deletedCodeAt'
OR attributes ? '_deletedByProductId'
OR attributes ? '_productDeletedAt';
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_soft_delete_identity_valid",
ADD CONSTRAINT "ProductSKU_soft_delete_identity_valid"
CHECK (
(
"deletedAt" IS NULL
AND "originalSkuCode" IS NULL
AND "deletedCodeAt" IS NULL
AND "deletedByProductId" IS NULL
AND "productDeletedAt" IS NULL
)
OR
(
"deletedAt" IS NOT NULL
AND NULLIF(btrim(COALESCE("originalSkuCode", '')), '') IS NOT NULL
AND "originalSkuCode" = upper(btrim("originalSkuCode"))
AND ("deletedByProductId" IS NULL OR NULLIF(btrim("deletedByProductId"), '') IS NOT NULL)
)
);
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_no_soft_delete_lifecycle_attributes",
ADD CONSTRAINT "ProductSKU_no_soft_delete_lifecycle_attributes"
CHECK (
NOT (
attributes ? '_originalSkuCode'
OR attributes ? '_deletedCodeAt'
OR attributes ? '_deletedByProductId'
OR attributes ? '_productDeletedAt'
)
);
`,
},
{
targetRelation: 'external.materials_v1',
targetRelations: ['external.materials_v1', 'external.product_bom_v1'],
path: 'prisma/migrations/20260601016000_tighten_external_material_and_bom_contract/migration.sql',
alwaysApply: true,
targetOnlyReadinessAssurance: 'external-readonly-contract',
},
{
targetRelation: '"Hardware"',
targetRelations: ['"Hardware"', '"Product"', '"ProductSKU"', '"Substrate"'],
path: 'prisma/migrations/20260601017000_align_migration_replay_to_current_schema/migration.sql',
columnCheck: {
tableName: 'Hardware',
columnName: 'geoWidth',
},
columnDefaultChecks: [
{
tableName: 'AssemblyStep',
columnName: 'partIds',
expectedDefault: null,
},
{
tableName: 'AssemblyStep',
columnName: 'hardwareIds',
expectedDefault: null,
},
],
},
{
targetRelation: '"MasterDataQualityDecision"',
path: 'prisma/migrations/20260601018000_add_master_data_quality_decisions/migration.sql',
guardChecks: [
{
type: 'partial-unique-index',
name: 'MasterDataQualityDecision_open_target_key',
tableName: 'MasterDataQualityDecision',
predicatePattern: 'status = \'APPROVED_INCOMPLETE\'',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_target_not_blank',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_status_allowed',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_reason_not_blank',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_decidedBy_not_blank',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_resolved_state_valid',
tableName: 'MasterDataQualityDecision',
},
],
repairSql: `
ALTER TABLE "MasterDataQualityDecision"
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_target_not_blank",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_status_allowed",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_reason_not_blank",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_decidedBy_not_blank",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_resolved_state_valid",
ADD CONSTRAINT "MasterDataQualityDecision_target_not_blank" CHECK (
NULLIF(btrim("targetType"), '') IS NOT NULL
AND NULLIF(btrim("targetId"), '') IS NOT NULL
),
ADD CONSTRAINT "MasterDataQualityDecision_status_allowed" CHECK (
"status" IN ('APPROVED_INCOMPLETE', 'RESOLVED')
),
ADD CONSTRAINT "MasterDataQualityDecision_reason_not_blank" CHECK (
NULLIF(btrim("reason"), '') IS NOT NULL
),
ADD CONSTRAINT "MasterDataQualityDecision_decidedBy_not_blank" CHECK (
NULLIF(btrim("decidedBy"), '') IS NOT NULL
),
ADD CONSTRAINT "MasterDataQualityDecision_resolved_state_valid" CHECK (
("status" <> 'RESOLVED' AND "resolvedAt" IS NULL AND "resolvedBy" IS NULL)
OR
("status" = 'RESOLVED' AND "resolvedAt" IS NOT NULL AND NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS "MasterDataQualityDecision_open_target_key"
ON "MasterDataQualityDecision"("targetType", "targetId")
WHERE "status" = 'APPROVED_INCOMPLETE';
`,
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260601019000_add_formal_cost_bom_guards/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'ProductSKU_price_cost_stock_non_negative',
tableName: 'ProductSKU',
},
],
},
{
targetRelation: '"AssetVariant"',
targetRelations: ['"AssetSeries"', '"AssetFamily"', '"AssetVariant"'],
path: 'prisma/migrations/20260601020000_add_asset_center_source_guards/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'AssetSeries_identity_valid',
tableName: 'AssetSeries',
},
{
type: 'check-constraint',
name: 'AssetSeries_metadata_object',
tableName: 'AssetSeries',
},
{
type: 'check-constraint',
name: 'AssetFamily_identity_valid',
tableName: 'AssetFamily',
},
{
type: 'check-constraint',
name: 'AssetFamily_metadata_object',
tableName: 'AssetFamily',
},
{
type: 'check-constraint',
name: 'AssetFamily_lifecycle_state_valid',
tableName: 'AssetFamily',
},
{
type: 'check-constraint',
name: 'AssetVariant_identity_valid',
tableName: 'AssetVariant',
},
{
type: 'check-constraint',
name: 'AssetVariant_json_shape_valid',
tableName: 'AssetVariant',
},
{
type: 'check-constraint',
name: 'AssetVariant_cost_weight_valid',
tableName: 'AssetVariant',
},
{
type: 'check-constraint',
name: 'AssetVariant_lifecycle_state_valid',
tableName: 'AssetVariant',
},
{
type: 'check-constraint',
name: 'AssetVariant_inheritance_not_self',
tableName: 'AssetVariant',
},
],
},
{
targetRelation: '"PreSku"',
targetRelations: ['"DraftGroup"', '"PreSku"'],
path: 'prisma/migrations/20260601021000_add_draft_pre_sku_source_guards/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'DraftGroup_identity_valid',
tableName: 'DraftGroup',
},
{
type: 'check-constraint',
name: 'DraftGroup_summary_object',
tableName: 'DraftGroup',
},
{
type: 'check-constraint',
name: 'DraftGroup_optional_text_not_blank',
tableName: 'DraftGroup',
},
{
type: 'check-constraint',
name: 'PreSku_identity_valid',
tableName: 'PreSku',
},
{
type: 'check-constraint',
name: 'PreSku_process_snapshots_shape_valid',
tableName: 'PreSku',
},
{
type: 'check-constraint',
name: 'PreSku_weight_valid',
tableName: 'PreSku',
},
{
type: 'check-constraint',
name: 'PreSku_component_codes_valid',
tableName: 'PreSku',
},
{
type: 'check-constraint',
name: 'PreSku_optional_text_not_blank',
tableName: 'PreSku',
},
],
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260601022000_add_product_sku_attributes_truth_guard/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'ProductSKU_no_formal_truth_attributes',
tableName: 'ProductSKU',
},
],
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260601023000_add_product_sku_asset_profile_deleted_at_trigger/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'trigger',
name: 'ProductSKU_sync_asset_profile_deleted_at',
tableName: 'ProductSKU',
},
],
},
{
targetRelation: '"ProductSKU"',
path: 'prisma/migrations/20260601024000_add_product_sku_soft_delete_tombstone_guard/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'ProductSKU_soft_delete_tombstone_valid',
tableName: 'ProductSKU',
},
],
repairSql: `
UPDATE "ProductSKU"
SET "deletedCodeAt" = COALESCE("deletedCodeAt", "deletedAt")
WHERE "deletedAt" IS NOT NULL
AND "deletedCodeAt" IS NULL;
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_soft_delete_tombstone_valid",
ADD CONSTRAINT "ProductSKU_soft_delete_tombstone_valid"
CHECK (
"deletedAt" IS NULL
OR (
"deletedCodeAt" IS NOT NULL
AND (
("deletedByProductId" IS NULL AND "productDeletedAt" IS NULL)
OR (
NULLIF(btrim(COALESCE("deletedByProductId", '')), '') IS NOT NULL
AND "productDeletedAt" IS NOT NULL
)
)
)
);
`,
},
{
targetRelation: '"MasterDataQualityDecision"',
path: 'prisma/migrations/20260601025000_tighten_master_data_quality_decision_text_guards/migration.sql',
alwaysApply: true,
guardChecks: [
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_reason_not_placeholder',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_decidedBy_not_placeholder',
tableName: 'MasterDataQualityDecision',
},
{
type: 'check-constraint',
name: 'MasterDataQualityDecision_resolvedBy_not_placeholder',
tableName: 'MasterDataQualityDecision',
},
],
dataChecks: [
{
name: 'master_data_quality_decision_placeholder_text_zero',
expectedValue: 0,
sql: `
WITH placeholder_tokens AS (
SELECT unnest(ARRAY[
'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd',
'default', 'test', '测试', '待补', '待补齐', '待确认', '未知',
'默认', '默认值', '暂无'
]) AS token
)
SELECT COUNT(*)::integer AS row_count /* data-check */
FROM "MasterDataQualityDecision"
WHERE (
NULLIF(btrim(COALESCE("reason", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("reason"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
)
OR (
NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
)
OR (
NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL
AND (
lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) IN (
SELECT token FROM placeholder_tokens
)
OR regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)'
)
);
`,
},
],
repairSql: `
ALTER TABLE "MasterDataQualityDecision"
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_reason_not_placeholder",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_decidedBy_not_placeholder",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_resolvedBy_not_placeholder",
ADD CONSTRAINT "MasterDataQualityDecision_reason_not_placeholder"
CHECK (
lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) NOT IN (
'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd',
'default', 'test', '测试', '待补', '待补齐', '待确认', '未知',
'默认', '默认值', '暂无'
)
AND regexp_replace(btrim("reason"), '\\s+', '', 'g') !~ '(待补|待确认|默认值)'
),
ADD CONSTRAINT "MasterDataQualityDecision_decidedBy_not_placeholder"
CHECK (
lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) NOT IN (
'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd',
'default', 'test', '测试', '待补', '待补齐', '待确认', '未知',
'默认', '默认值', '暂无'
)
AND regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') !~ '(待补|待确认|默认值)'
),
ADD CONSTRAINT "MasterDataQualityDecision_resolvedBy_not_placeholder"
CHECK (
"resolvedBy" IS NULL
OR (
lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) NOT IN (
'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd',
'default', 'test', '测试', '待补', '待补齐', '待确认', '未知',
'默认', '默认值', '暂无'
)
AND regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') !~ '(待补|待确认|默认值)'
)
);
`,
},
]
function getMigrationTimestamp(migrationPath) {
const match = String(migrationPath).match(/prisma\/migrations\/(\d{14})_[^/]+\/migration\.sql$/)
return match?.[1] ?? null
}
function getMigrationTargetRelations(migration) {
if (Array.isArray(migration.targetRelations)) {
return migration.targetRelations
}
return migration.targetRelation ? [migration.targetRelation] : []
}
function getRelationSearchTerms(relationName) {
const raw = String(relationName)
const unquoted = raw.replace(/"/g, '')
const relationOnly = unquoted.split('.').pop()
return [...new Set([raw, unquoted, relationOnly].filter(Boolean).map((term) => term.toLowerCase()))]
}
function validateRequiredMigrationCatalog(options = {}) {
const rootDir = options.rootDir ?? ROOT_DIR
const migrations = options.migrations ?? REQUIRED_MIGRATIONS
const errors = []
const seenPaths = new Set()
let previousPath = ''
validatePrismaMigrationDirectories({ rootDir })
migrations.forEach((migration, index) => {
const label = migration.path || `#${index + 1}`
if (!migration.path || typeof migration.path !== 'string') {
errors.push(`迁移清单第 ${index + 1} 项缺少 path。`)
return
}
if (seenPaths.has(migration.path)) {
errors.push(`迁移清单存在重复 path:${migration.path}`)
}
seenPaths.add(migration.path)
if (previousPath && migration.path < previousPath) {
errors.push(`迁移清单顺序必须按 path 升序排列:${previousPath} -> ${migration.path}`)
}
previousPath = migration.path
if (!getMigrationTimestamp(migration.path)) {
errors.push(`迁移 path 不符合 prisma/migrations/<timestamp>_name/migration.sql:${migration.path}`)
}
const targetRelations = getMigrationTargetRelations(migration)
if (targetRelations.length === 0) {
errors.push(`迁移缺少 targetRelation / targetRelations:${label}`)
}
if (migration.columnCheck) {
if (!migration.columnCheck.tableName || !migration.columnCheck.columnName) {
errors.push(`迁移 columnCheck 必须同时声明 tableName 和 columnName:${label}`)
}
const normalizedTargets = targetRelations
.map((relationName) => String(relationName).replace(/"/g, '').split('.').pop())
if (!normalizedTargets.includes(migration.columnCheck.tableName)) {
errors.push(`迁移 columnCheck.tableName 必须属于 targetRelation:${label}`)
}
}
if (Array.isArray(migration.columnDefaultChecks)) {
migration.columnDefaultChecks.forEach((check, checkIndex) => {
if (!check.tableName || !check.columnName || !Object.hasOwn(check, 'expectedDefault')) {
errors.push(`迁移 columnDefaultChecks[${checkIndex}] 必须声明 tableName、columnName 和 expectedDefault:${label}`)
}
})
}
if (Array.isArray(migration.guardChecks)) {
migration.guardChecks.forEach((check, checkIndex) => {
if (!MIGRATION_GUARD_CHECK_TYPES.has(check.type) || !check.name || !check.tableName) {
errors.push(`迁移 guardChecks[${checkIndex}] 必须声明 type、name 和 tableName:${label}`)
}
if (check.type === 'partial-unique-index' && !check.predicatePattern) {
errors.push(`迁移 guardChecks[${checkIndex}] 的 partial-unique-index 必须声明 predicatePattern:${label}`)
}
})
}
if (Array.isArray(migration.relationColumnChecks)) {
migration.relationColumnChecks.forEach((check, checkIndex) => {
if (!check.relationName || !Array.isArray(check.columnNames) || check.columnNames.length === 0) {
errors.push(`迁移 relationColumnChecks[${checkIndex}] 必须声明 relationName 和 columnNames:${label}`)
}
})
}
if (Array.isArray(migration.dataChecks)) {
migration.dataChecks.forEach((check, checkIndex) => {
if (!check.name || typeof check.sql !== 'string' || !Object.hasOwn(check, 'expectedValue')) {
errors.push(`迁移 dataChecks[${checkIndex}] 必须声明 name、sql 和 expectedValue:${label}`)
}
})
}
if (migration.alwaysApply && !hasExplicitReadinessChecks(migration)) {
if (!TARGET_ONLY_READINESS_ASSURANCES.has(migration.targetOnlyReadinessAssurance)) {
errors.push(
`alwaysApply 迁移没有显式 readiness check 时,必须声明 targetOnlyReadinessAssurance:${label}`,
)
}
}
const absolutePath = path.join(rootDir, migration.path)
if (!fs.existsSync(absolutePath)) {
errors.push(`迁移文件不存在:${migration.path}`)
return
}
const sql = fs.readFileSync(absolutePath, 'utf8').toLowerCase()
targetRelations.forEach((relationName) => {
const hasRelation = getRelationSearchTerms(relationName).some((term) => sql.includes(term))
if (!hasRelation) {
errors.push(`迁移文件未引用声明的 targetRelation:${migration.path} -> ${relationName}`)
}
})
})
if (errors.length > 0) {
throw new Error(`主数据治理迁移清单无效:\n${errors.join('\n')}`)
}
return {
migrationCount: migrations.length,
structureMigrationCount: migrations.filter((migration) => !migration.alwaysApply).length,
guardMigrationCount: migrations.filter((migration) => migration.alwaysApply).length,
targetOnlyAlwaysApplyMigrationCount: migrations.filter(
(migration) => migration.alwaysApply && !hasExplicitReadinessChecks(migration),
).length,
}
}
function validatePrismaMigrationDirectories(options = {}) {
const rootDir = options.rootDir ?? ROOT_DIR
const migrationsDir = path.join(rootDir, 'prisma/migrations')
const errors = []
if (!fs.existsSync(migrationsDir)) {
throw new Error(`Prisma migrations 目录不存在:${path.relative(rootDir, migrationsDir)}`)
}
const dirs = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
dirs.forEach((dirName) => {
const migrationSqlPath = path.join(migrationsDir, dirName, 'migration.sql')
if (!fs.existsSync(migrationSqlPath)) {
errors.push(`Prisma migration 缺少 migration.sql:prisma/migrations/${dirName}`)
}
if (
!STANDARD_PRISMA_MIGRATION_DIR_PATTERN.test(dirName) &&
!LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS.has(dirName)
) {
errors.push(
`Prisma migration 目录名不符合 <timestamp>_name 且未登记为历史例外:prisma/migrations/${dirName}`,
)
}
})
if (errors.length > 0) {
throw new Error(`Prisma migration 目录无效:\n${errors.join('\n')}`)
}
return {
migrationDirectoryCount: dirs.length,
legacyNonStandardMigrationDirectories: dirs.filter((dirName) =>
LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS.has(dirName),
),
}
}
function classifyAppliedMigrationPaths(appliedMigrationPaths, migrations = REQUIRED_MIGRATIONS) {
const idempotentMigrationPaths = new Set(
migrations
.filter((migration) => migration.alwaysApply)
.map((migration) => migration.path),
)
return {
structureMigrations: appliedMigrationPaths.filter(
(migrationPath) => !idempotentMigrationPaths.has(migrationPath),
),
idempotentMigrations: appliedMigrationPaths.filter(
(migrationPath) => idempotentMigrationPaths.has(migrationPath),
),
}
}
function getPendingStructureMigrationPaths(appliedMigrationPaths, migrations = REQUIRED_MIGRATIONS) {
return classifyAppliedMigrationPaths(appliedMigrationPaths, migrations).structureMigrations
}
async function relationExists(client, relationName) {
const result = await client.query('SELECT to_regclass($1) AS relation_name', [relationName])
return result.rows[0]?.relation_name !== null
}
async function columnExists(client, tableName, columnName) {
const result = await client.query(`
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
LIMIT 1
`, [tableName, columnName])
return result.rowCount > 0
}
async function columnDefaultMatches(client, tableName, columnName, expectedDefault) {
const result = await client.query(`
SELECT column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
LIMIT 1
`, [tableName, columnName])
if (result.rowCount === 0) return false
const actualDefault = result.rows[0]?.column_default ?? null
return actualDefault === expectedDefault
}
function parseRelationName(relationName) {
const normalized = String(relationName).replace(/"/g, '')
const parts = normalized.split('.').filter(Boolean)
if (parts.length === 1) {
return { schemaName: 'public', tableName: parts[0] }
}
return {
schemaName: parts[parts.length - 2],
tableName: parts[parts.length - 1],
}
}
async function relationColumnExists(client, relationName, columnName) {
const { schemaName, tableName } = parseRelationName(relationName)
const result = await client.query(`
SELECT 1
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = $2
AND column_name = $3
LIMIT 1
`, [schemaName, tableName, columnName])
return result.rowCount > 0
}
async function dataCheckPasses(client, check) {
let result
try {
result = await client.query(check.sql)
} catch (error) {
if (error?.code === '42P01') {
return false
}
throw error
}
const row = result.rows[0] ?? {}
const firstValue = Object.values(row)[0]
return firstValue === check.expectedValue
}
function normalizeSqlFragment(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.replace(/""/g, '"')
.trim()
}
async function guardExists(client, guard) {
if (guard.type === 'trigger') {
const result = await client.query(`
SELECT 1
FROM information_schema.triggers
WHERE event_object_schema = 'public'
AND event_object_table = $1
AND trigger_name = $2
LIMIT 1
`, [guard.tableName, guard.name])
return result.rowCount > 0
}
if (guard.type === 'check-constraint') {
const result = await client.query(`
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = $1
AND c.conname = $2
AND c.contype = 'c'
LIMIT 1
`, [guard.tableName, guard.name])
return result.rowCount > 0
}
if (guard.type === 'unique-index' || guard.type === 'partial-unique-index') {
const result = await client.query(`
SELECT
idx.indisunique AS is_unique,
pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
pg_get_indexdef(cls.oid) AS definition
FROM pg_index idx
JOIN pg_class cls ON cls.oid = idx.indexrelid
JOIN pg_class table_cls ON table_cls.oid = idx.indrelid
JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace
WHERE ns.nspname = 'public'
AND cls.relname = $1
AND table_cls.relname = $2
LIMIT 1
`, [guard.name, guard.tableName])
const row = result.rows[0]
const predicate = normalizeSqlFragment(row?.predicate)
const definition = normalizeSqlFragment(row?.definition)
const hasExpectedPredicate =
guard.type !== 'partial-unique-index' ||
(predicate && predicate.includes(normalizeSqlFragment(guard.predicatePattern)))
const hasExpectedDefinition =
!guard.definitionPattern || definition.includes(normalizeSqlFragment(guard.definitionPattern))
return Boolean(row?.is_unique) && hasExpectedPredicate && hasExpectedDefinition
}
return false
}
async function assertMigrationTargetReady(client, migration) {
const targetRelations = getMigrationTargetRelations(migration)
const relationChecks = await Promise.all(
targetRelations.map(async (relationName) => ({
relationName,
exists: await relationExists(client, relationName),
})),
)
const missingRelations = relationChecks
.filter((check) => !check.exists)
.map((check) => check.relationName)
if (missingRelations.length > 0) {
throw new Error(
`治理迁移执行后未生成声明的 targetRelation:${migration.path} -> ${missingRelations.join(', ')}`,
)
}
if (
migration.columnCheck &&
!await columnExists(client, migration.columnCheck.tableName, migration.columnCheck.columnName)
) {
throw new Error(
`治理迁移执行后未生成声明的 columnCheck:${migration.path} -> ${migration.columnCheck.tableName}.${migration.columnCheck.columnName}`,
)
}
if (Array.isArray(migration.columnDefaultChecks)) {
for (const check of migration.columnDefaultChecks) {
if (!await columnDefaultMatches(client, check.tableName, check.columnName, check.expectedDefault)) {
throw new Error(
`治理迁移执行后未满足声明的 columnDefaultCheck:${migration.path} -> ${check.tableName}.${check.columnName}`,
)
}
}
}
if (Array.isArray(migration.guardChecks)) {
for (const check of migration.guardChecks) {
if (!await guardExists(client, check)) {
throw new Error(
`治理迁移执行后未满足声明的 guardCheck:${migration.path} -> ${check.type}:${check.tableName}.${check.name}`,
)
}
}
}
if (Array.isArray(migration.relationColumnChecks)) {
for (const check of migration.relationColumnChecks) {
for (const columnName of check.columnNames) {
if (!await relationColumnExists(client, check.relationName, columnName)) {
throw new Error(
`治理迁移执行后未满足声明的 relationColumnCheck:${migration.path} -> ${check.relationName}.${columnName}`,
)
}
}
}
}
if (Array.isArray(migration.dataChecks)) {
for (const check of migration.dataChecks) {
if (!await dataCheckPasses(client, check)) {
throw new Error(
`治理迁移执行后未满足声明的 dataCheck:${migration.path} -> ${check.name}`,
)
}
}
}
}
async function applyMissingMigrationFiles(client, migrations = REQUIRED_MIGRATIONS) {
const applied = []
const readinessByPath = new Map()
for (const migration of migrations) {
readinessByPath.set(migration.path, await getMigrationApplyReadiness(client, migration))
}
const shouldReplayIdempotentMigrations = Array.from(readinessByPath.values()).some(
(readiness) => !readiness.ready,
)
for (const migration of migrations) {
const readiness = readinessByPath.get(migration.path)
const shouldReplayCurrentIdempotent = Boolean(
migration.alwaysApply &&
shouldReplayIdempotentMigrations &&
!hasExplicitReadinessChecks(migration),
)
if (readiness.ready && !shouldReplayCurrentIdempotent) {
continue
}
const canUseRepairSql = Boolean(
readiness.targetExists &&
readiness.columnReady &&
readiness.columnDefaultReady &&
readiness.relationColumnReady &&
!readiness.dataReady &&
migration.repairSql,
)
const canUseRepairSqlForGuard = Boolean(
readiness.targetExists &&
readiness.columnReady &&
readiness.columnDefaultReady &&
readiness.relationColumnReady &&
readiness.dataReady &&
!readiness.guardReady &&
migration.repairSql,
)
const absolutePath = path.join(ROOT_DIR, migration.path)
await client.query(
canUseRepairSql || canUseRepairSqlForGuard
? migration.repairSql
: fs.readFileSync(absolutePath, 'utf8'),
)
await assertMigrationTargetReady(client, migration)
applied.push(migration.path)
}
const replayedIdempotentMigrations = applied.some((migrationPath) => {
const migration = migrations.find((item) => item.path === migrationPath)
return migration?.alwaysApply
})
if (!replayedIdempotentMigrations) {
return applied
}
for (const migration of migrations.filter((item) => !item.alwaysApply)) {
const readiness = await getMigrationApplyReadiness(client, migration)
if (readiness.ready) continue
const canUseRepairSql = Boolean(
readiness.targetExists &&
readiness.columnReady &&
readiness.columnDefaultReady &&
readiness.relationColumnReady &&
migration.repairSql,
)
const absolutePath = path.join(ROOT_DIR, migration.path)
await client.query(canUseRepairSql ? migration.repairSql : fs.readFileSync(absolutePath, 'utf8'))
await assertMigrationTargetReady(client, migration)
if (!applied.includes(migration.path)) {
applied.push(migration.path)
}
}
return applied
}
async function getMigrationApplyReadiness(client, migration) {
const targetRelations = getMigrationTargetRelations(migration)
const targetExists = (
await Promise.all(targetRelations.map((relationName) => relationExists(client, relationName)))
).every(Boolean)
const columnReady = !migration.columnCheck ||
await columnExists(client, migration.columnCheck.tableName, migration.columnCheck.columnName)
const columnDefaultReady = await migrationColumnDefaultChecksPass(client, migration)
const guardReady = await migrationGuardChecksPass(client, migration)
const relationColumnReady = await migrationRelationColumnChecksPass(client, migration)
const dataReady = targetExists
? await migrationDataChecksPass(client, migration)
: false
return {
targetExists,
columnReady,
columnDefaultReady,
guardReady,
relationColumnReady,
dataReady,
ready:
targetExists &&
columnReady &&
columnDefaultReady &&
guardReady &&
relationColumnReady &&
dataReady,
}
}
async function migrationColumnDefaultChecksPass(client, migration) {
if (!Array.isArray(migration.columnDefaultChecks) || migration.columnDefaultChecks.length === 0) {
return true
}
const results = await Promise.all(
migration.columnDefaultChecks.map((check) =>
columnDefaultMatches(client, check.tableName, check.columnName, check.expectedDefault),
),
)
return results.every(Boolean)
}
async function migrationGuardChecksPass(client, migration) {
if (!Array.isArray(migration.guardChecks) || migration.guardChecks.length === 0) {
return true
}
const results = await Promise.all(migration.guardChecks.map((check) => guardExists(client, check)))
return results.every(Boolean)
}
async function migrationRelationColumnChecksPass(client, migration) {
if (!Array.isArray(migration.relationColumnChecks) || migration.relationColumnChecks.length === 0) {
return true
}
const results = []
for (const check of migration.relationColumnChecks) {
for (const columnName of check.columnNames) {
results.push(await relationColumnExists(client, check.relationName, columnName))
}
}
return results.every(Boolean)
}
async function migrationDataChecksPass(client, migration) {
if (!Array.isArray(migration.dataChecks) || migration.dataChecks.length === 0) {
return true
}
const results = []
for (const check of migration.dataChecks) {
results.push(await dataCheckPasses(client, check))
}
return results.every(Boolean)
}
module.exports = {
REQUIRED_MIGRATIONS,
LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS,
applyMissingMigrationFiles,
assertMigrationTargetReady,
classifyAppliedMigrationPaths,
columnDefaultMatches,
columnExists,
dataCheckPasses,
guardExists,
hasExplicitReadinessChecks,
getPendingStructureMigrationPaths,
getMigrationTargetRelations,
getMigrationTimestamp,
relationColumnExists,
relationExists,
validateRequiredMigrationCatalog,
validatePrismaMigrationDirectories,
}