diff --git a/.changeset/unify-tailordb-deploy-on-snapshot.md b/.changeset/unify-tailordb-deploy-on-snapshot.md new file mode 100644 index 000000000..81fe46744 --- /dev/null +++ b/.changeset/unify-tailordb-deploy-on-snapshot.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor deploy` so decimal fields without an explicit `scale` no longer show spurious drift against the platform (which materializes the default `6`). Deploy now plans and applies through the same snapshot pipeline as `tailordb migrate`. diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index a1ef20189..4d3e625e9 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.test.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.test.ts @@ -47,6 +47,7 @@ function emptyResults(): PlanResults { context: { workspaceId: "ws", application: {} as PlanResults["tailorDB"]["context"]["application"], + tailorDBInputs: [], config: {} as PlanResults["tailorDB"]["context"]["config"], noSchemaCheck: false, }, diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts index 6b4563285..dac6db783 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -772,6 +772,7 @@ describe("applyTailorDB phase separation", () => { name: "test-app", tailorDBServices: [mockTailorDBService], } as unknown as Application, + tailorDBInputs: [], config: mockConfig, noSchemaCheck: true, // Skip migration checks in unit tests }, @@ -907,6 +908,7 @@ describe("applyTailorDB migration label reconciliation (--no-schema-check)", () name: "test-app", tailorDBServices: [mockTailorDBService], } as unknown as Application, + tailorDBInputs: [], config, noSchemaCheck: true, }, diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index cea9dc745..5063fa489 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -53,7 +53,17 @@ import { formatMigrationNumber, compareRemoteWithSnapshot, formatSchemaDrifts, + createSnapshotType, getLatestMigrationNumber, + isSnapshotFieldRefOperand, + type SnapshotFieldConfig, + type TailorDBSnapshotType, + type SnapshotRecordPermission, + type SnapshotActionPermission, + type SnapshotPermissionCondition, + type SnapshotPermissionOperand, + type SnapshotGqlPermission, + type SnapshotGqlPermissionPolicy, } from "@/cli/commands/tailordb/migrate/snapshot"; import { type TailorDBService } from "@/cli/services/tailordb/service"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; @@ -84,16 +94,6 @@ import type { import type { LoadedConfig } from "@/cli/shared/config-loader"; import type { Executor } from "@/types/executor.generated"; import type { EnumValue } from "@/types/field-types"; -import type { - PermissionOperand, - StandardActionPermission, - StandardGqlPermissionPolicy, - StandardPermissionCondition, - StandardTailorTypeGqlPermission, - StandardTailorTypePermission, - OperatorFieldConfig, - TailorDBType, -} from "@/types/tailordb"; import type { GqlOperations, TailorDBServiceConfig } from "@/types/tailordb.generated"; import type { SetMetadataRequestSchema } from "@tailor-proto/tailor/v1/metadata_pb"; @@ -252,7 +252,7 @@ type ValidateAndDetectResult = { * Validate migration files and detect pending migrations * @param {OperatorClient} client - Operator client instance * @param {string} workspaceId - Workspace ID - * @param {ReadonlyMap>} typesByNamespace - Types by namespace + * @param {ReadonlyMap>} typesByNamespace - Types by namespace * @param {LoadedConfig} config - Loaded application config (includes path) * @param {boolean} noSchemaCheck - Whether to skip schema diff check * @returns {Promise} Pending migrations and namespaces that have migration directories configured @@ -260,7 +260,7 @@ type ValidateAndDetectResult = { async function validateAndDetectMigrations( client: OperatorClient, workspaceId: string, - typesByNamespace: ReadonlyMap>, + typesByNamespace: ReadonlyMap>, config: LoadedConfig, noSchemaCheck: boolean, ): Promise { @@ -434,13 +434,10 @@ export async function applyTailorDB( if (phase === "create-update") { // Validate and detect migrations - // Build types by namespace map - const typesByNamespace = new Map>(); - for (const tailordb of migrationContext.application.tailorDBServices) { - const types = tailordb.types; - if (types) { - typesByNamespace.set(tailordb.namespace, types); - } + // Build types by namespace map (snapshot-shaped, the canonical deploy form) + const typesByNamespace = new Map>(); + for (const tailordb of migrationContext.tailorDBInputs) { + typesByNamespace.set(tailordb.namespace, tailordb.types); } const { pendingMigrations, namespacesWithMigrations } = await validateAndDetectMigrations( @@ -1028,6 +1025,36 @@ async function executeSingleMigrationPostPhase( * @param context - Planning context * @returns Planned changes */ +/** + * Canonical input shape consumed by every TailorDB plan/proto step. + * The deploy pipeline funnels `TailorDBService` through `createSnapshotType` so + * that comparison, manifest generation and migration drift checks all read the + * same snapshot-shaped data, keeping platform-side normalization (e.g. decimal + * scale) in one place. + */ +type TailorDBDeployInput = { + namespace: string; + config: TailorDBServiceConfig; + types: Record; +}; + +/** + * Convert a runtime TailorDBService to the snapshot-shaped deploy input. + * @param service - Loaded TailorDB service (after `loadTypes()`) + * @returns The canonical snapshot-shaped deploy input for downstream plan/apply phases. + */ +function toTailorDBDeployInput(service: TailorDBService): TailorDBDeployInput { + const types: Record = {}; + for (const [typeName, type] of Object.entries(service.types)) { + types[typeName] = createSnapshotType(type); + } + return { + namespace: service.namespace, + config: service.config, + types, + }; +} + export async function planTailorDB(context: PlanContext) { const { client, @@ -1038,11 +1065,11 @@ export async function planTailorDB(context: PlanContext) { noSchemaCheck, forceApplyAll = false, } = context; - const tailordbs: TailorDBService[] = []; + const tailordbs: TailorDBDeployInput[] = []; if (!forRemoval) { for (const tailordb of application.tailorDBServices) { await tailordb.loadTypes(); - tailordbs.push(tailordb); + tailordbs.push(toTailorDBDeployInput(tailordb)); } } const executors = forRemoval @@ -1073,6 +1100,7 @@ export async function planTailorDB(context: PlanContext) { context: { workspaceId, application, + tailorDBInputs: tailordbs, config, noSchemaCheck: noSchemaCheck ?? false, }, @@ -1189,7 +1217,7 @@ function areTailorDBServicesEqual( namespace?: { name?: string }; defaultTimezone?: string; }, - desired: Readonly, + desired: Readonly, ): boolean { return areNormalizedEqual( normalizeComparableTailorDBService({ @@ -1208,7 +1236,7 @@ async function planServices( workspaceId: string, appName: string, appId: string | undefined, - tailordbs: ReadonlyArray, + tailordbs: ReadonlyArray, ) { const changeSet = createChangeSet( "TailorDB services", @@ -1339,10 +1367,10 @@ type DeleteType = { async function planTypes( client: OperatorClient, workspaceId: string, - tailordbs: ReadonlyArray, + tailordbs: ReadonlyArray, executors: ReadonlyArray, deletedServices: ReadonlyArray, - filteredTypesByNamespace?: Map>, + filteredTypesByNamespace?: Map>, forceApplyAll = false, ) { const changeSet = createChangeSet("TailorDB types"); @@ -1591,18 +1619,18 @@ function isNumericLikeValue(value: string | number | bigint): boolean { // TODO(remiposo): Copied the type-processor / aggregator processing almost as-is. // This will need refactoring later. /** - * Generate a TailorDB type manifest from parsed type - * @param {TailorDBType} type - Parsed TailorDB type + * Generate a TailorDB type manifest from snapshot-shaped type + * @param {TailorDBSnapshotType} type - Snapshot-shaped TailorDB type * @param {ReadonlySet} executorUsedTypes - Set of types used by executors * @param {GqlOperations} [namespaceGqlOperations] - Default gqlOperations for the namespace (already normalized) * @returns {MessageInitShape} Type manifest */ function generateTailorDBTypeManifest( - type: TailorDBType, + type: TailorDBSnapshotType, executorUsedTypes: ReadonlySet, namespaceGqlOperations?: GqlOperations, ): MessageInitShape { - // This ensures that explicitly provided pluralForm like "PurchaseOrderList" becomes "purchaseOrderList" + // Ensures that explicitly provided pluralForm like "PurchaseOrderList" becomes "purchaseOrderList". const pluralForm = inflection.camelize(type.pluralForm, true); const defaultSettings: { @@ -1654,7 +1682,7 @@ function generateTailorDBTypeManifest( Object.keys(type.fields) .filter((fieldName) => fieldName !== "id") .forEach((fieldName) => { - const fieldConfig = type.fields[fieldName].config; + const fieldConfig = type.fields[fieldName]; const fieldType = fieldConfig.type; const fieldEntry: MessageInitShape = { type: fieldType, @@ -1667,7 +1695,7 @@ function generateTailorDBTypeManifest( foreignKey: fieldConfig.foreignKey || false, foreignKeyType: fieldConfig.foreignKeyType, foreignKeyField: fieldConfig.foreignKeyField, - required: fieldConfig.required !== false, + required: fieldConfig.required, vector: fieldConfig.vector || false, ...toProtoFieldHooks(fieldConfig), ...(fieldConfig.serial && { @@ -1697,7 +1725,7 @@ function generateTailorDBTypeManifest( MessageInitShape > = {}; - for (const [relationName, rel] of Object.entries(type.forwardRelationships)) { + for (const [relationName, rel] of Object.entries(type.forwardRelationships ?? {})) { relationships[relationName] = { refType: rel.targetType, refField: rel.sourceField, @@ -1707,7 +1735,7 @@ function generateTailorDBTypeManifest( }; } - for (const [relationName, rel] of Object.entries(type.backwardRelationships)) { + for (const [relationName, rel] of Object.entries(type.backwardRelationships ?? {})) { relationships[relationName] = { refType: rel.targetType, refField: rel.targetField, @@ -1744,7 +1772,7 @@ function generateTailorDBTypeManifest( update: [], delete: [], }; - const permission = type.permissions.record + const permission = type.permissions?.record ? protoPermission(type.permissions.record) : defaultPermission; @@ -1765,7 +1793,7 @@ function generateTailorDBTypeManifest( } function toProtoFieldValidate( - fieldConfig: OperatorFieldConfig, + fieldConfig: SnapshotFieldConfig, ): MessageInitShape["validate"] { return (fieldConfig.validate || []).map((val) => ({ action: TailorDBType_PermitAction.DENY, @@ -1779,7 +1807,7 @@ function toProtoFieldValidate( } function toProtoFieldHooks( - fieldConfig: OperatorFieldConfig, + fieldConfig: SnapshotFieldConfig, ): Pick, "hooks"> | Record { if (!fieldConfig.hooks) { return {}; @@ -1801,7 +1829,7 @@ function toProtoFieldHooks( } function processNestedFields( - fields: Record, + fields: Record, ): Record> { const nestedFields: Record> = {}; @@ -1815,7 +1843,7 @@ function processNestedFields( allowedValues: nestedFieldConfig.allowedValues || [], description: nestedFieldConfig.description || "", validate: toProtoFieldValidate(nestedFieldConfig), - required: nestedFieldConfig.required ?? true, + required: nestedFieldConfig.required, array: nestedFieldConfig.array ?? false, index: false, unique: false, @@ -1833,7 +1861,7 @@ function processNestedFields( allowedValues: nestedType === "enum" ? nestedFieldConfig.allowedValues || [] : [], description: nestedFieldConfig.description || "", validate: toProtoFieldValidate(nestedFieldConfig), - required: nestedFieldConfig.required ?? true, + required: nestedFieldConfig.required, array: nestedFieldConfig.array ?? false, index: false, unique: false, @@ -1862,17 +1890,18 @@ function processNestedFields( } function protoPermission( - permission: StandardTailorTypePermission, + permission: SnapshotRecordPermission, ): MessageInitShape { - const ret: MessageInitShape = {}; - for (const [key, policies] of Object.entries(permission)) { - ret[key as keyof StandardTailorTypePermission] = policies.map((policy) => protoPolicy(policy)); - } - return ret; + return { + create: permission.create.map((policy) => protoPolicy(policy)), + read: permission.read.map((policy) => protoPolicy(policy)), + update: permission.update.map((policy) => protoPolicy(policy)), + delete: permission.delete.map((policy) => protoPolicy(policy)), + }; } function protoPolicy( - policy: StandardActionPermission<"record">, + policy: SnapshotActionPermission, ): MessageInitShape { let permit: TailorDBType_Permission_Permit; switch (policy.permit) { @@ -1893,7 +1922,7 @@ function protoPolicy( } function protoCondition( - condition: StandardPermissionCondition<"record">, + condition: SnapshotPermissionCondition, ): MessageInitShape { const [left, operator, right] = condition; @@ -1930,47 +1959,27 @@ function protoCondition( } function protoOperand( - operand: PermissionOperand, + operand: SnapshotPermissionOperand, ): MessageInitShape { - if (typeof operand === "object" && !Array.isArray(operand)) { + if (isSnapshotFieldRefOperand(operand)) { if ("user" in operand) { - return { - kind: { - case: "userField", - value: operand.user, - }, - }; - } else if ("record" in operand) { - return { - kind: { - case: "recordField", - value: operand.record, - }, - }; - } else if ("newRecord" in operand) { - return { - kind: { - case: "newRecordField", - value: operand.newRecord, - }, - }; - } else if ("oldRecord" in operand) { - return { - kind: { - case: "oldRecordField", - value: operand.oldRecord, - }, - }; - } else { - throw new Error(`Unknown operand: ${JSON.stringify(operand)}`); + return { kind: { case: "userField", value: operand.user } }; } + if ("record" in operand) { + return { kind: { case: "recordField", value: operand.record } }; + } + if ("newRecord" in operand) { + return { kind: { case: "newRecordField", value: operand.newRecord } }; + } + if ("oldRecord" in operand) { + return { kind: { case: "oldRecordField", value: operand.oldRecord } }; + } + operand satisfies never; + throw new Error(`Unknown field-ref operand shape: ${JSON.stringify(operand)}`); } return { - kind: { - case: "value", - value: fromJson(ValueSchema, operand), - }, + kind: { case: "value", value: fromJson(ValueSchema, operand) }, }; } @@ -1992,7 +2001,7 @@ type DeleteGqlPermission = { async function planGqlPermissions( client: OperatorClient, workspaceId: string, - tailordbs: ReadonlyArray, + tailordbs: ReadonlyArray, deletedServices: ReadonlyArray, forceApplyAll = false, ) { @@ -2028,7 +2037,7 @@ async function planGqlPermissions( const types = tailordb.types; for (const typeName of Object.keys(types)) { - const gqlPermission = types[typeName].permissions.gql; + const gqlPermission = types[typeName].permissions?.gql; if (!gqlPermission) { continue; } @@ -2115,7 +2124,7 @@ function normalizeComparableGqlPermission(permission: unknown) { } function protoGqlPermission( - permission: StandardTailorTypeGqlPermission, + permission: SnapshotGqlPermission, ): MessageInitShape { return { policies: permission.map((policy) => protoGqlPolicy(policy)), @@ -2123,7 +2132,7 @@ function protoGqlPermission( } function protoGqlPolicy( - policy: StandardGqlPermissionPolicy, + policy: SnapshotGqlPermissionPolicy, ): MessageInitShape { const actions: TailorDBGQLPermission_Action[] = []; for (const action of policy.actions) { @@ -2173,7 +2182,7 @@ function protoGqlPolicy( } function protoGqlCondition( - condition: StandardPermissionCondition<"gql">, + condition: SnapshotPermissionCondition, ): MessageInitShape { const [left, operator, right] = condition; @@ -2210,24 +2219,20 @@ function protoGqlCondition( } function protoGqlOperand( - operand: PermissionOperand, + operand: SnapshotPermissionOperand, ): MessageInitShape { - if (typeof operand === "object" && !Array.isArray(operand)) { + if (isSnapshotFieldRefOperand(operand)) { if ("user" in operand) { - return { - kind: { - case: "userField", - value: operand.user, - }, - }; + return { kind: { case: "userField", value: operand.user } }; } + throw new Error( + `Unsupported field-ref operand in GQL permission: ${JSON.stringify(operand)} ` + + `— GQL permissions only support { user } field references`, + ); } return { - kind: { - case: "value", - value: fromJson(ValueSchema, operand), - }, + kind: { case: "value", value: fromJson(ValueSchema, operand) }, }; } @@ -2244,12 +2249,12 @@ interface MigrationCheckResult { /** * Check if there are schema differences between migration snapshots and local definitions - * @param {ReadonlyMap>} typesByNamespace - Types by namespace + * @param {ReadonlyMap>} typesByNamespace - Snapshot-shaped local types by namespace * @param {NamespaceWithMigrations[]} namespacesWithMigrations - Namespaces with migrations config * @returns {Promise} Results for each namespace */ async function checkMigrationDiffs( - typesByNamespace: ReadonlyMap>, + typesByNamespace: ReadonlyMap>, namespacesWithMigrations: NamespaceWithMigrations[], ): Promise { const results: MigrationCheckResult[] = []; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts index fdaa9ccab..0d9379b17 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts @@ -38,6 +38,7 @@ function createMockSnapshot( } snapshotTypes[typeName] = { name: typeName, + pluralForm: `${typeName}s`, fields, }; } diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts index af31dd5fa..8cb9fee3b 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts @@ -10,7 +10,7 @@ import { getMigrationFilePath, type SchemaSnapshot, type SnapshotFieldConfig, - type SnapshotType, + type TailorDBSnapshotType, } from "./snapshot"; import type { MigrationDiff } from "./diff-calculator"; @@ -203,12 +203,12 @@ function generateEmptyDbTypes(namespace: string): string { /** * Generate table type definition from a snapshot type - * @param {SnapshotType} type - Snapshot type + * @param {TailorDBSnapshotType} type - Snapshot type * @param {BreakingChangeFieldInfo} breakingChangeFields - Breaking change field info * @returns {{ typeDef: string; usedTimestamp: boolean; usedColumnType: boolean }} Generated type and utility type usage */ function generateTableType( - type: SnapshotType, + type: TailorDBSnapshotType, breakingChangeFields: BreakingChangeFieldInfo, ): { typeDef: string; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts index 93c29c0b6..a551c0ede 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts @@ -5,15 +5,16 @@ import { generateAllTypeManifestsFromSnapshot, compareSnapshotWithRemote, } from "./snapshot-manifest"; -import type { SchemaSnapshot, SnapshotType, SnapshotRecordPermission } from "./snapshot"; +import type { SchemaSnapshot, TailorDBSnapshotType, SnapshotRecordPermission } from "./snapshot"; describe("snapshot-manifest", () => { function createTestSnapshotType( name: string, - overrides: Partial = {}, - ): SnapshotType { + overrides: Partial = {}, + ): TailorDBSnapshotType { return { name, + pluralForm: `${name}s`, fields: { id: { type: "uuid", required: true }, name: { type: "string", required: true }, @@ -23,7 +24,7 @@ describe("snapshot-manifest", () => { } function createTestSnapshot( - types: Record, + types: Record, namespace = "tailordb", ): SchemaSnapshot { return { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts index 6380b8e27..5bf728a52 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts @@ -23,11 +23,12 @@ import { type TailorDBTypeSchema, } from "@tailor-proto/tailor/v1/tailordb_resource_pb"; import * as inflection from "inflection"; +import { isSnapshotFieldRefOperand } from "./snapshot"; import type { SchemaSnapshot, SnapshotEnumValue, SnapshotFieldConfig, - SnapshotType, + TailorDBSnapshotType, SnapshotRelationship, SnapshotRecordPermission, SnapshotActionPermission, @@ -53,17 +54,15 @@ export interface GenerateManifestOptions { /** * Generate a TailorDB type manifest from a snapshot type - * @param {SnapshotType} snapshotType - Snapshot type to generate manifest from + * @param {TailorDBSnapshotType} snapshotType - Snapshot type to generate manifest from * @param {GenerateManifestOptions} options - Generation options * @returns {MessageInitShape} Type manifest */ export function generateTailorDBTypeManifestFromSnapshot( - snapshotType: SnapshotType, + snapshotType: TailorDBSnapshotType, options: GenerateManifestOptions = {}, ): MessageInitShape { - const pluralForm = snapshotType.pluralForm - ? inflection.camelize(snapshotType.pluralForm, true) - : inflection.camelize(inflection.pluralize(snapshotType.name), true); + const pluralForm = inflection.camelize(snapshotType.pluralForm, true); // Build settings const defaultSettings: { @@ -455,50 +454,25 @@ function convertConditionToProto( function convertOperandToProto( operand: SnapshotPermissionOperand, ): MessageInitShape { - if (typeof operand === "object" && operand !== null && !Array.isArray(operand)) { - const obj = operand as Record; - if ("user" in obj && typeof obj.user === "string") { - return { - kind: { - case: "userField", - value: obj.user, - }, - }; + if (isSnapshotFieldRefOperand(operand)) { + if ("user" in operand) { + return { kind: { case: "userField", value: operand.user } }; } - if ("record" in obj && typeof obj.record === "string") { - return { - kind: { - case: "recordField", - value: obj.record, - }, - }; + if ("record" in operand) { + return { kind: { case: "recordField", value: operand.record } }; } - if ("newRecord" in obj && typeof obj.newRecord === "string") { - return { - kind: { - case: "newRecordField", - value: obj.newRecord, - }, - }; + if ("newRecord" in operand) { + return { kind: { case: "newRecordField", value: operand.newRecord } }; } - if ("oldRecord" in obj && typeof obj.oldRecord === "string") { - return { - kind: { - case: "oldRecordField", - value: obj.oldRecord, - }, - }; + if ("oldRecord" in operand) { + return { kind: { case: "oldRecordField", value: operand.oldRecord } }; } - // Fall through to value handling for unknown objects + operand satisfies never; + throw new Error(`Unknown field-ref operand shape: ${JSON.stringify(operand)}`); } - // Value operand (primitive or array) - // Cast to JsonValue type for fromJson return { - kind: { - case: "value", - value: fromJson(ValueSchema, operand as Parameters[1]), - }, + kind: { case: "value", value: fromJson(ValueSchema, operand) }, }; } diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts index 955be9986..49e7b2916 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.test.ts @@ -220,6 +220,7 @@ describe("snapshot", () => { types: { NewType: { name: "NewType", + pluralForm: "NewTypes", fields: { id: { type: "uuid", required: true } }, }, }, @@ -239,6 +240,7 @@ describe("snapshot", () => { types: { OldType: { name: "OldType", + pluralForm: "OldTypes", fields: { id: { type: "uuid", required: true } }, }, }, @@ -258,6 +260,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -267,6 +270,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: false }, @@ -288,6 +292,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -297,6 +302,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, requiredField: { type: "string", required: true }, @@ -317,6 +323,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, name: { type: "string", required: true }, @@ -329,6 +336,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -347,6 +355,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, age: { type: "string", required: false }, @@ -359,6 +368,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, age: { type: "number", required: false }, @@ -374,12 +384,18 @@ describe("snapshot", () => { expect(diff.breakingChanges[0].reason).toContain("Field type changed"); }); - it("treats decimal field with unset scale as equivalent to platform default", () => { + it("normalizes decimal scale at compare entry so missing scale matches platform default", () => { + // Reproduces the production scenario where one snapshot was loaded from + // an older file that omitted `scale` and the other was produced by + // `createSnapshotType` (which materializes the platform default of 6). + // compareSnapshots normalizes both inputs at the entry, so the diff must + // come out empty even though the literal shapes differ. const previous: SchemaSnapshot = { ...createEmptySnapshot(), types: { Order: { name: "Order", + pluralForm: "Orders", fields: { id: { type: "uuid", required: true }, amount: { type: "decimal", required: true }, @@ -387,18 +403,15 @@ describe("snapshot", () => { }, }, }; - const current: SchemaSnapshot = { - ...createEmptySnapshot(), - types: { - Order: { - name: "Order", - fields: { - id: { type: "uuid", required: true }, - amount: { type: "decimal", required: true, scale: 6 }, - }, - }, + const current = createSnapshotFromLocalTypes( + { + Order: createMockType("Order", { + id: { name: "id", config: { type: "uuid", required: true } }, + amount: { name: "amount", config: { type: "decimal", required: true } }, + }), }, - }; + namespace, + ); const diff = compareSnapshots(previous, current); @@ -412,6 +425,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: false }, @@ -424,6 +438,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: true }, @@ -444,6 +459,7 @@ describe("snapshot", () => { types: { Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, tags: { type: "string", required: false, array: true }, @@ -456,6 +472,7 @@ describe("snapshot", () => { types: { Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, tags: { type: "string", required: false, array: false }, @@ -476,6 +493,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: true, unique: false }, @@ -488,6 +506,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: true, unique: true }, @@ -508,6 +527,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -529,6 +549,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -554,6 +575,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -570,6 +592,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -595,6 +618,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -611,6 +635,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -637,6 +662,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -653,10 +679,12 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, authorId: { type: "uuid", required: true }, @@ -670,6 +698,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, backwardRelationships: { posts: { @@ -683,6 +712,7 @@ describe("snapshot", () => { }, Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, authorId: { type: "uuid", required: true }, @@ -726,6 +756,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -738,7 +769,8 @@ describe("snapshot", () => { }), }; - const diff = compareLocalTypesWithSnapshot(previousSnapshot, localTypes, namespace); + const snapshotTypes = createSnapshotFromLocalTypes(localTypes, namespace).types; + const diff = compareLocalTypesWithSnapshot(previousSnapshot, snapshotTypes, namespace); expect(diff.changes.length).toBe(1); expect(diff.changes[0].kind).toBe("field_added"); @@ -909,6 +941,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -998,6 +1031,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, name: { type: "string", required: true }, @@ -1024,6 +1058,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1063,6 +1098,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1121,6 +1157,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1136,6 +1173,7 @@ describe("snapshot", () => { typeName: "Post", after: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, title: { type: "string", required: true }, @@ -1166,10 +1204,12 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, OldType: { name: "OldType", + pluralForm: "OldTypes", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1185,6 +1225,7 @@ describe("snapshot", () => { typeName: "OldType", before: { name: "OldType", + pluralForm: "OldTypes", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1216,10 +1257,12 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true }, authorId: { type: "uuid", required: true }, @@ -1515,6 +1558,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, name: { type: "string", required: true }, @@ -1542,10 +1586,12 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, Post: { name: "Post", + pluralForm: "Posts", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1571,6 +1617,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, @@ -1599,6 +1646,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, email: { type: "string", required: false }, @@ -1627,6 +1675,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, }, @@ -1655,6 +1704,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, age: { type: "number", required: false }, @@ -1685,6 +1735,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, name: { type: "string", required: false }, @@ -1714,6 +1765,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true }, tags: { type: "string", required: false, array: true }, @@ -1743,6 +1795,7 @@ describe("snapshot", () => { types: { Task: { name: "Task", + pluralForm: "Tasks", fields: { id: { type: "uuid", required: true }, status: { @@ -1772,21 +1825,32 @@ describe("snapshot", () => { expect(drifts[0].details).toContain("allowedValues"); }); - it("treats decimal field with unset scale as platform default (no drift)", () => { - const snapshot: SchemaSnapshot = { - version: SCHEMA_SNAPSHOT_VERSION, - namespace, - createdAt: new Date().toISOString(), - types: { - Order: { - name: "Order", - fields: { - id: { type: "uuid", required: true }, - amount: { type: "decimal", required: true }, + it("normalizes decimal scale at compare entry so missing scale matches remote default", () => { + // The snapshot is written from disk without an explicit scale (legacy / + // user-authored form). compareRemoteWithSnapshot normalizes the snapshot + // at entry so it becomes equivalent to a remote that has materialized + // the platform-default scale of 6. + const snapshotPath = path.join(testDir, "decimal-default", SCHEMA_FILE_NAME); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: { + Order: { + name: "Order", + pluralForm: "Orders", + fields: { + id: { type: "uuid", required: true }, + amount: { type: "decimal", required: true }, + }, }, }, - }, - }; + }), + ); + const snapshot = loadSnapshot(snapshotPath); const remoteTypes = [ createMockRemoteType("Order", { @@ -1799,7 +1863,7 @@ describe("snapshot", () => { expect(drifts).toEqual([]); }); - it("detects drift when decimal scale actually differs from platform default", () => { + it("detects drift when decimal scale differs from snapshot", () => { const snapshot: SchemaSnapshot = { version: SCHEMA_SNAPSHOT_VERSION, namespace, @@ -1807,9 +1871,10 @@ describe("snapshot", () => { types: { Order: { name: "Order", + pluralForm: "Orders", fields: { id: { type: "uuid", required: true }, - amount: { type: "decimal", required: true }, + amount: { type: "decimal", required: true, scale: 6 }, }, }, }, @@ -1836,6 +1901,7 @@ describe("snapshot", () => { types: { User: { name: "User", + pluralForm: "Users", fields: { id: { type: "uuid", required: true } }, }, }, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts index 0becbf7dd..191370d91 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts @@ -3,6 +3,7 @@ */ import * as fs from "node:fs"; +import * as inflection from "inflection"; import * as path from "pathe"; import { type MigrationDiff, @@ -52,16 +53,42 @@ export const MIGRATION_NUMBER_PATTERN = /^\d{4}$/; export const DEFAULT_DECIMAL_SCALE = 6; /** - * Resolve the effective scale of a field for comparison purposes. - * Decimal fields without an explicit scale are stored on the platform with the - * default scale, so we normalize unset values to the default to avoid false drift. - * @param {SnapshotFieldConfig} field - Field configuration - * @returns {number | undefined} Effective scale, or undefined for non-decimal fields without scale + * Normalize a snapshot field in place so the snapshot becomes the canonical + * form for comparison. Currently fills in the platform default decimal scale + * when omitted, which avoids false drift between local schemas (where scale + * may be omitted) and the platform (which always materializes a scale). + * @param {SnapshotFieldConfig} field - Field configuration to normalize */ -function getEffectiveScale(field: SnapshotFieldConfig): number | undefined { - if (field.scale !== undefined) return field.scale; - if (field.type === "decimal") return DEFAULT_DECIMAL_SCALE; - return undefined; +function normalizeSnapshotField(field: SnapshotFieldConfig): void { + if (field.type === "decimal" && field.scale === undefined) { + field.scale = DEFAULT_DECIMAL_SCALE; + } + if (field.fields) { + for (const nested of Object.values(field.fields)) { + normalizeSnapshotField(nested); + } + } +} + +/** + * Normalize a snapshot type in place to the canonical comparison shape. + * Currently fills: + * - `pluralForm` via inflection when missing (legacy snapshots written + * before `pluralForm` became required may omit it) + * - per-field `scale` defaults via {@link normalizeSnapshotField} + * + * Idempotent — safe to call multiple times on the same input. + * @param {TailorDBSnapshotType} type - Snapshot type to normalize + */ +function normalizeSnapshotType(type: TailorDBSnapshotType): void { + // `pluralForm` is typed as required by TailorDBSnapshotType, but JSON.parse'd legacy + // snapshots may have it undefined at runtime — backfill from inflection. + if (!(type as { pluralForm?: string }).pluralForm) { + type.pluralForm = inflection.pluralize(type.name); + } + for (const field of Object.values(type.fields)) { + normalizeSnapshotField(field); + } } // Re-export SCHEMA_SNAPSHOT_VERSION for convenience @@ -153,14 +180,27 @@ export interface SnapshotRelationship { // ============================================================================ /** - * Permission operand types + * Field-reference operand in a permission condition. Always an object with + * exactly one of `user` / `record` / `newRecord` / `oldRecord` keys. */ -export type SnapshotPermissionOperand = +export type SnapshotFieldRefOperand = | { user: string } | { record: string } | { newRecord: string } - | { oldRecord: string } - | unknown; // ValueOperand (primitives, arrays) + | { oldRecord: string }; + +/** + * Literal value operand (right-hand side of a permission condition). Matches + * the SDK-level value operand surface — primitives and their arrays — as + * defined in the Zod parser schema (RecordPermissionOperandSchema / + * GqlPermissionOperandSchema in parser/service/tailordb/schema.ts). + */ +export type SnapshotValueOperand = string | boolean | string[] | boolean[]; + +/** + * Permission operand union. Either a field-ref object or a literal value. + */ +export type SnapshotPermissionOperand = SnapshotFieldRefOperand | SnapshotValueOperand; /** * Permission operators @@ -176,6 +216,17 @@ export type SnapshotPermissionCondition = readonly [ SnapshotPermissionOperand, ]; +/** + * Type guard: is the operand a field-reference (object) operand? + * @param {SnapshotPermissionOperand} operand - Operand to test + * @returns {boolean} True if operand is a field-ref (not a value operand) + */ +export function isSnapshotFieldRefOperand( + operand: SnapshotPermissionOperand, +): operand is SnapshotFieldRefOperand { + return typeof operand === "object" && operand !== null && !Array.isArray(operand); +} + /** * Action permission policy */ @@ -223,11 +274,14 @@ export interface SnapshotGqlPermissionPolicy { export type SnapshotGqlPermission = readonly SnapshotGqlPermissionPolicy[]; /** - * Type definition in schema snapshot + * Type definition in schema snapshot. + * `pluralForm` is always materialized — either set by the SDK user, derived + * via inflection at snapshot construction, or backfilled when loading legacy + * snapshots in `loadSnapshot`. */ -export interface SnapshotType { +export interface TailorDBSnapshotType { name: string; - pluralForm?: string; + pluralForm: string; description?: string; fields: Record; settings?: { @@ -260,7 +314,7 @@ export interface SchemaSnapshot { version: typeof SCHEMA_SNAPSHOT_VERSION; namespace: string; createdAt: string; - types: Record; + types: Record; } /** @@ -430,6 +484,7 @@ function createSnapshotFieldConfig(field: ParsedField): SnapshotFieldConfig { } } + normalizeSnapshotField(config); return config; } @@ -501,27 +556,28 @@ function createSnapshotFieldConfigFromOperatorConfig( } } + normalizeSnapshotField(config); return config; } /** * Create a snapshot type from a parsed type * @param {TailorDBType} type - Parsed TailorDB type definition - * @returns {SnapshotType} Snapshot type configuration + * @returns {TailorDBSnapshotType} Snapshot type configuration */ -function createSnapshotType(type: TailorDBType): SnapshotType { +export function createSnapshotType(type: TailorDBType): TailorDBSnapshotType { const fields: Record = {}; for (const [fieldName, field] of Object.entries(type.fields)) { fields[fieldName] = createSnapshotFieldConfig(field); } - const snapshotType: SnapshotType = { + const snapshotType: TailorDBSnapshotType = { name: type.name, + pluralForm: type.pluralForm || inflection.pluralize(type.name), fields, }; - if (type.pluralForm) snapshotType.pluralForm = type.pluralForm; if (type.description) snapshotType.description = type.description; if (type.settings) { snapshotType.settings = {}; @@ -644,7 +700,7 @@ export function createSnapshotFromLocalTypes( types: Record, namespace: string, ): SchemaSnapshot { - const snapshotTypes: Record = {}; + const snapshotTypes: Record = {}; for (const [typeName, type] of Object.entries(types)) { snapshotTypes[typeName] = createSnapshotType(type); @@ -669,7 +725,11 @@ export function createSnapshotFromLocalTypes( */ export function loadSnapshot(filePath: string): SchemaSnapshot { const content = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(content) as SchemaSnapshot; + const snapshot = JSON.parse(content) as SchemaSnapshot; + for (const type of Object.values(snapshot.types)) { + normalizeSnapshotType(type); + } + return snapshot; } /** @@ -759,7 +819,7 @@ function applyDiffToSnapshot(snapshot: SchemaSnapshot, diff: MigrationDiff): Sch for (const change of diff.changes) { switch (change.kind) { case "type_added": - types[change.typeName] = change.after as SnapshotType; + types[change.typeName] = change.after as TailorDBSnapshotType; break; case "type_removed": delete types[change.typeName]; @@ -1041,7 +1101,7 @@ function areFieldsDifferent(oldField: SnapshotFieldConfig, newField: SnapshotFie if ((oldSerial.format ?? "") !== (newSerial.format ?? "")) return true; } - if (getEffectiveScale(oldField) !== getEffectiveScale(newField)) return true; + if (oldField.scale !== newField.scale) return true; const oldFields = oldField.fields ?? {}; const newFields = newField.fields ?? {}; @@ -1181,8 +1241,8 @@ function addChange( function compareTypeFields( ctx: DiffContext, typeName: string, - prevType: SnapshotType, - currType: SnapshotType, + prevType: TailorDBSnapshotType, + currType: TailorDBSnapshotType, ): void { const prevFieldNames = new Set(Object.keys(prevType.fields)); const currFieldNames = new Set(Object.keys(currType.fields)); @@ -1492,6 +1552,13 @@ function comparePermissions( * @returns {MigrationDiff} Migration diff between snapshots */ export function compareSnapshots(previous: SchemaSnapshot, current: SchemaSnapshot): MigrationDiff { + // Defense-in-depth: factory functions (`createSnapshotType`, `loadSnapshot`, + // `convertRemoteFieldsToSnapshot`) are expected to produce normalized + // snapshots, but a caller assembling a SchemaSnapshot literal would otherwise + // produce silent false drift (e.g. decimal scale 6 vs unset). Idempotent. + for (const type of Object.values(previous.types)) normalizeSnapshotType(type); + for (const type of Object.values(current.types)) normalizeSnapshotType(type); + const ctx: DiffContext = { changes: [], breakingChanges: [] }; const previousTypeNames = new Set(Object.keys(previous.types)); @@ -1574,18 +1641,27 @@ export function compareSnapshots(previous: SchemaSnapshot, current: SchemaSnapsh } /** - * Compare local types with a snapshot and generate a diff + * Compare a snapshot against canonical TailorDBSnapshotType-shaped local types. + * Callers are expected to pre-convert TailorDBService.types to TailorDBSnapshotType via + * `createSnapshotType`. As a safety net, `compareSnapshots` re-runs idempotent + * normalization on both sides, so a caller that forgets will still get correct + * comparisons (no silent false drift). * @param {SchemaSnapshot} snapshot - Schema snapshot to compare against - * @param {Record} localTypes - Local type definitions + * @param {Record} localTypes - Local snapshot-shaped types * @param {string} namespace - Namespace for comparison * @returns {MigrationDiff} Migration diff */ export function compareLocalTypesWithSnapshot( snapshot: SchemaSnapshot, - localTypes: Record, + localTypes: Record, namespace: string, ): MigrationDiff { - const currentSnapshot = createSnapshotFromLocalTypes(localTypes, namespace); + const currentSnapshot: SchemaSnapshot = { + version: SCHEMA_SNAPSHOT_VERSION, + namespace, + createdAt: new Date().toISOString(), + types: localTypes, + }; return compareSnapshots(snapshot, currentSnapshot); } @@ -1830,6 +1906,7 @@ function convertRemoteFieldsToSnapshot( // TODO: Add nested field conversion when remote API supports it + normalizeSnapshotField(config); fields[fieldName] = config; } @@ -1921,10 +1998,8 @@ function compareFields( differences.push(`vector: remote=${remoteVector}, expected=${snapshotVector}`); } - const remoteScale = getEffectiveScale(remoteField); - const snapshotScale = getEffectiveScale(snapshotField); - if (remoteScale !== snapshotScale) { - differences.push(`scale: remote=${remoteScale}, expected=${snapshotScale}`); + if (remoteField.scale !== snapshotField.scale) { + differences.push(`scale: remote=${remoteField.scale}, expected=${snapshotField.scale}`); } if (differences.length > 0) { @@ -1954,6 +2029,9 @@ export function compareRemoteWithSnapshot( remoteTypes: ProtoTailorDBType[], snapshot: SchemaSnapshot, ): SchemaDrift[] { + // Defense-in-depth normalize — matches `compareSnapshots`. Idempotent. + for (const type of Object.values(snapshot.types)) normalizeSnapshotType(type); + const drifts: SchemaDrift[] = []; // Build maps for easy lookup diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.test.ts index 481e42d51..4f7d6ec41 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.test.ts @@ -46,6 +46,7 @@ describe("template-generator", () => { const snapshot = createTestSnapshot({ User: { name: "User", + pluralForm: "Users", fields: { name: { type: "string", required: true }, email: { type: "string", required: false }, @@ -114,6 +115,7 @@ describe("template-generator", () => { const previousSnapshot = createTestSnapshot({ User: { name: "User", + pluralForm: "Users", fields: { name: { type: "string", required: true }, }, @@ -221,6 +223,7 @@ describe("template-generator", () => { const snapshotWithOldField = createTestSnapshot({ User: { name: "User", + pluralForm: "Users", fields: { name: { type: "string", required: true }, oldField: { type: "string", required: false }, @@ -262,6 +265,7 @@ describe("template-generator", () => { const snapshotWithoutUnique = createTestSnapshot({ User: { name: "User", + pluralForm: "Users", fields: { email: { type: "string", required: true, unique: false }, }, @@ -305,6 +309,7 @@ describe("template-generator", () => { const snapshotWithAllEnumValues = createTestSnapshot({ Task: { name: "Task", + pluralForm: "Tasks", fields: { status: { type: "enum", diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index 0acfae88e..818707706 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -208,7 +208,7 @@ export { getMigrationDirPath, getMigrationFilePath, type SchemaSnapshot, - type SnapshotType, + type TailorDBSnapshotType, type SnapshotFieldConfig, type MigrationInfo, } from "./commands/tailordb/migrate/snapshot";