From 367b701777ef5f3687a92469b4d4e368c6db7958 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 07:18:10 +0000 Subject: [PATCH] feat(spec)!: resolve the three cross-form dual-source names (#4539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three baseline rows where the two declarations sharing a name did not even share a FORM (type vs const, or two unrelated functions), so a wrong import-path pick had no shape overlap to hide behind. Each judged against a three-repo import-level scan (framework, cloud, objectui — the latter two contain zero references to all three names). - ShareRecipientType: the security zod enum (authorable sharing-RULE recipients) keeps the name; the contracts type is a DIFFERENT concept — the recipient_type a sys_record_share ROW may carry — renamed RecordShareRecipientType, member set aligned to the storage select it actually mirrors ('role', never persistable, -> 'position', completing the #2724 D3 rename its changelog already claimed). plugin-sharing re-export renamed in lockstep; only 'user' is enforced today, unchanged. - TransformType: the data import-mapping enum keeps the name; shared's differently-shaped config-union pair (zero importers for either name in all three repos) renamed FieldMappingTransformSchema / FieldMappingTransform. Renamed rather than alias-deleted because build-docs derives `import type` examples by stripping 'Schema' (filed unassigned as #4570). json-schema.manifest key moved with it. - suggestFieldType: diffed the two implementations FIRST — they were never forks of one function: shared/suggestions.zod is the typo-suggester (string -> string[] candidates, feeds the zod error map, keeps the name on . and ./shared); data/type-compat is the deterministic SQL-column -> FieldType mapper (ADR-0015), renamed suggestFieldTypeForSqlType. Divergence pinned in type-compat.test.ts: ('varchar(255)') -> [] vs 'text'; ('text_area') -> ['textarea'] vs undefined; ('int') -> ['number'] vs 'number'. Not a live bug: each caller imports the one it means; the hazard was authoring-time wrong-pick, where `[]` is truthy. All 3 dual-source-exports.baseline.json rows deleted; api-surface and reference docs regenerated via check:generated --fix (8/8 green); check:dual-source-exports green at 35 baseline rows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --- .../dual-source-cross-form-convergence.md | 67 +++++++++++++++++++ .../docs/getting-started/quick-reference.mdx | 2 +- content/docs/references/shared/mapping.mdx | 6 +- packages/plugins/plugin-sharing/src/index.ts | 2 +- .../src/objects/sys-record-share.object.ts | 6 +- .../src/sharing-service.test.ts | 4 +- .../src/external-datasource-service.ts | 6 +- packages/spec/api-surface.json | 8 +-- .../spec/dual-source-exports.baseline.json | 5 +- packages/spec/json-schema.manifest.json | 2 +- .../src/contracts/sharing-service.test.ts | 53 +++++++++++++++ .../spec/src/contracts/sharing-service.ts | 24 +++++-- packages/spec/src/data/type-compat.test.ts | 42 +++++++++--- packages/spec/src/data/type-compat.ts | 11 ++- packages/spec/src/shared/mapping.test.ts | 26 +++---- packages/spec/src/shared/mapping.zod.ts | 18 +++-- 16 files changed, 224 insertions(+), 58 deletions(-) create mode 100644 .changeset/dual-source-cross-form-convergence.md create mode 100644 packages/spec/src/contracts/sharing-service.test.ts diff --git a/.changeset/dual-source-cross-form-convergence.md b/.changeset/dual-source-cross-form-convergence.md new file mode 100644 index 0000000000..68356f3560 --- /dev/null +++ b/.changeset/dual-source-cross-form-convergence.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": major +"@objectstack/plugin-sharing": major +--- + +feat(spec)!: resolve the three cross-form dual-source names — ShareRecipientType, TransformType, suggestFieldType (#4539) + +Three `dual-source-exports.baseline.json` rows where the two declarations +sharing a name did not even share a FORM (type vs const, or two unrelated +functions), so a wrong import-path pick had no shape overlap to hide behind +and failed far from the cause. Each judged against a three-repo import-level +scan (framework, cloud, objectui — the latter two contained zero references +to all three names). All three rows are deleted from the baseline. + +**Renamed — `./contracts` `ShareRecipientType` → `RecordShareRecipientType`:** + +Two live concepts shared the name. The security zod enum +(`user | team | position | unit_and_subordinates | business_unit`) is the +authorable sharing-RULE recipient vocabulary and keeps the name. The contracts +type describes a different thing — the `recipient_type` a `sys_record_share` +ROW may carry — and its claim to "mirror spec/security" had been false since +`group`→`team`/`guest` were retired there. Its member set is now aligned to +the storage-side gate it actually mirrors, the `SysRecordShare` +`recipient_type` select: `role` (never persistable, zero producers) is +replaced by `position`. Only `user` is enforced (and written) today; +`ISharingService.grant` keeps refusing every other value (ADR-0078). +Fix: `import type { ShareRecipientType } from '@objectstack/spec/contracts'` +(or from `@objectstack/plugin-sharing`, whose re-export is renamed in +lockstep) → `RecordShareRecipientType`; code that named the `'role'` member +was describing a value no row could ever hold — use the rule vocabulary +(`SharingRuleRecipientType`) if a role recipient was meant. + +**Renamed — `./shared` `TransformTypeSchema` / `TransformType` → +`FieldMappingTransformSchema` / `FieldMappingTransform`:** + +`./data`'s `TransformType` (the authorable import-mapping enum +`none | constant | lookup | split | join | javascript | map`) is the live +declaration and keeps the name. `./shared` exported `TransformType` as the +inferred type of `TransformTypeSchema` — a differently-shaped discriminated +union of transform CONFIG objects — with zero importers for either name in +all three repos. The shared pair is renamed (not just the alias deleted): +the docs generator derives `import type { X }` examples by stripping +`Schema` from each schema const, so an alias-less `TransformTypeSchema` +would have kept generating a reference to an export that no longer exists. +Fix: `TransformTypeSchema` → `FieldMappingTransformSchema`, +`import type { TransformType } from '@objectstack/spec/shared'` → +`FieldMappingTransform` (same shape); importers who meant the import-mapping +enum import `TransformType` from `@objectstack/spec/data`. + +**Renamed — `./data` `suggestFieldType` → `suggestFieldTypeForSqlType`:** + +The only function-kind dual-source. The two implementations were never forks +of one function — different signatures, semantics and return types: +`shared/suggestions.zod.ts` (kept on `.` / `./shared` under the original +name) is the typo-suggester for an invalid authored FieldType +(`(input: string) => string[]`, alias table + Levenshtein, feeds the zod +error map), while `data/type-compat.ts` is the deterministic SQL-column → +FieldType mapper for external-datasource drafts +(`(rawType, dialect?) => FieldType | undefined`, ADR-0015 §4.6). Same input, +divergent outputs — `('varchar(255)')` → `[]` vs `'text'`; `('text_area')` → +`['textarea']` vs `undefined`; `('int')` → `['number']` vs `'number'` — and +the wrong pick compiled wherever the result was only truthiness-checked +(`[]` is truthy). Behavioral divergence is now pinned in +`data/type-compat.test.ts`. +Fix: `import { suggestFieldType } from '@objectstack/spec/data'` → +`suggestFieldTypeForSqlType` (same signature); imports from the root entry +or `./shared` are unaffected. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index de9833bb52..71f2ba9438 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -214,7 +214,7 @@ Common utilities used across all protocols. | **[Expression](/docs/references/shared/expression)** | `expression.zod.ts` | Expression, ExpressionInput | CEL expression values and inputs | | **[HTTP](/docs/references/shared/http)** | `http.zod.ts` | HttpRequest, HttpMethod, CorsConfig | HTTP utilities | | **[Identifiers](/docs/references/shared/identifiers)** | `identifiers.zod.ts` | SystemIdentifier, SnakeCaseIdentifier | Standard ID patterns | -| **[Mapping](/docs/references/shared/mapping)** | `mapping.zod.ts` | FieldMapping, TransformType | Field mapping utilities | +| **[Mapping](/docs/references/shared/mapping)** | `mapping.zod.ts` | FieldMapping, FieldMappingTransform | Field mapping utilities | | **[Connector Auth](/docs/references/shared/connector-auth)** | `connector-auth.zod.ts` | ConnectorAuthConfig | Connector auth patterns | ## QA Protocol (1 schema) diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx index e99bb01637..273aff16ad 100644 --- a/content/docs/references/shared/mapping.mdx +++ b/content/docs/references/shared/mapping.mdx @@ -62,8 +62,8 @@ defaultValue: 'Unknown' ## TypeScript Usage ```typescript -import { FieldMapping, TransformType } from '@objectstack/spec/shared'; -import type { FieldMapping, TransformType } from '@objectstack/spec/shared'; +import { FieldMapping, FieldMappingTransform } from '@objectstack/spec/shared'; +import type { FieldMapping, FieldMappingTransform } from '@objectstack/spec/shared'; // Validate data const result = FieldMapping.parse(data); @@ -85,7 +85,7 @@ const result = FieldMapping.parse(data); --- -## TransformType +## FieldMappingTransform ### Union Options diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index 140ef25080..4022819062 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -71,7 +71,7 @@ export type { GrantShareInput, SharingExecutionContext, ShareAccessLevel, - ShareRecipientType, + RecordShareRecipientType, ShareSource, SharingRuleRow, DefineSharingRuleInput, diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index f4ab5ae78c..6b91c6a432 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -22,9 +22,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Conventions: * - `object_name` is the short object name (e.g. `account`, `lead`). - * - `recipient_type` mirrors `ShareRecipientType` from the spec - * (`user` is enforced today; `group`/`position` are persisted for - * forward-compatibility). + * - `recipient_type` mirrors `RecordShareRecipientType` from + * `@objectstack/spec/contracts` (`user` is enforced today; + * `group`/`position` are persisted for forward-compatibility). * - `source = 'manual'` rows are created by a user via the REST * `POST /data/:object/:id/shares` endpoint. `source = 'rule'` rows * are materialised by the sharing-rule evaluator (future); the diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index 6ddfe4a6d1..c83a48bee6 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -939,10 +939,12 @@ describe('[ADR-0111 D7] no inert grants', () => { }); it('refuses non-user recipient types instead of persisting rows no gate reads', async () => { + // Every non-`user` member of RecordShareRecipientType — no `as any` + // needed since #4539 aligned the contract type to the storage select. for (const recipientType of ['group', 'position', 'unit_and_subordinates', 'guest'] as const) { await expect( svc.grant( - { object: 'account', recordId: 'a1', recipientId: 'g1', recipientType: recipientType as any }, + { object: 'account', recordId: 'a1', recipientId: 'g1', recipientType }, { userId: 'alice' }, ), ).rejects.toThrow(/VALIDATION_FAILED/); diff --git a/packages/services/service-datasource/src/external-datasource-service.ts b/packages/services/service-datasource/src/external-datasource-service.ts index 36260eb2bf..a2fbadff03 100644 --- a/packages/services/service-datasource/src/external-datasource-service.ts +++ b/packages/services/service-datasource/src/external-datasource-service.ts @@ -25,7 +25,7 @@ import type { } from '@objectstack/spec/contracts'; import type { SchemaDiffEntry } from '@objectstack/spec/shared'; import { - suggestFieldType, + suggestFieldTypeForSqlType, isCompatible, ExternalCatalogSchema, type ExternalCatalog, @@ -211,7 +211,7 @@ export class ExternalDatasourceService implements IExternalDatasourceService { if (exclude.has(col.name)) continue; const fieldName = opts.rename?.[col.name] ?? col.name; - const suggested = suggestFieldType(col.type, dialect); + const suggested = suggestFieldTypeForSqlType(col.type, dialect); const fieldType: FieldType = suggested ?? 'text'; if (!suggested) { review.push({ @@ -310,7 +310,7 @@ export class ExternalDatasourceService implements IExternalDatasourceService { sqlType: c.type, nullable: c.nullable, primaryKey: c.primaryKey, - suggestedFieldType: suggestFieldType(c.type, schema.dialect as SqlDialect), + suggestedFieldType: suggestFieldTypeForSqlType(c.type, schema.dialect as SqlDialect), })), }; }), diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 6bce99d899..a6718b2a93 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -709,7 +709,7 @@ "resolveSearchFields (function)", "sequenceWidth (function)", "stripLegacyApiMethods (function)", - "suggestFieldType (function)", + "suggestFieldTypeForSqlType (function)", "utcInstantMs (function)", "validateDriverConfig (function)", "valueSchemaFor (function)" @@ -3840,6 +3840,7 @@ "RealtimeSubscriptionFilter (interface)", "RealtimeSubscriptionOptions (interface)", "RecordShare (interface)", + "RecordShareRecipientType (type)", "RemoteTable (interface)", "ReportFormat (type)", "ReportQuery (interface)", @@ -3877,7 +3878,6 @@ "ShareLinkAudience (type)", "ShareLinkExecutionContext (interface)", "ShareLinkPermission (type)", - "ShareRecipientType (type)", "ShareSource (type)", "SharingExecutionContext (interface)", "SharingRuleEvaluationResult (interface)", @@ -4455,6 +4455,8 @@ "F (const)", "FieldMapping (type)", "FieldMappingSchema (const)", + "FieldMappingTransform (type)", + "FieldMappingTransformSchema (const)", "FieldName (type)", "FieldNameSchema (const)", "FlowName (type)", @@ -4507,8 +4509,6 @@ "SystemIdentifierSchema (const)", "TemplateExpressionInput (type)", "TemplateExpressionInputSchema (const)", - "TransformType (type)", - "TransformTypeSchema (const)", "VISIBILITY_ALIAS_KEYS (const)", "ViewName (type)", "ViewNameSchema (const)", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index fd934fa2d7..530bba8eaa 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -30,14 +30,11 @@ "RetryPolicySchema — [./automation (const)] ≠ [./system (const)]", "Session — [./api (type)] ≠ [./identity (type)]", "SessionSchema — [./api (const)] ≠ [./identity (const)]", - "ShareRecipientType — [./contracts (type)] ≠ [./security (const)]", "TenantPlan — [./cloud (type)] ≠ [./system (type)]", "TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]", - "TransformType — [./data (const)] ≠ [./shared (type)]", "WebhookConfig — [./api (type)] ≠ [./integration (type)]", "WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]", "WebhookEvent — [./api (type)] ≠ [./integration (type)]", - "WebhookEventSchema — [./api (const)] ≠ [./integration (const)]", - "suggestFieldType — [., ./shared (function)] ≠ [./data (function)]" + "WebhookEventSchema — [./api (const)] ≠ [./integration (const)]" ] } diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index cdc7a9a284..363ba5df49 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1137,6 +1137,7 @@ "shared/ExpressionInput", "shared/ExpressionMeta", "shared/FieldMapping", + "shared/FieldMappingTransform", "shared/FieldName", "shared/FlowName", "shared/HttpMethod", @@ -1156,7 +1157,6 @@ "shared/StaticMount", "shared/SystemIdentifier", "shared/TemplateExpressionInput", - "shared/TransformType", "shared/ViewName", "studio/ActionContribution", "studio/ActionLocation", diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts new file mode 100644 index 0000000000..ddc6c877ca --- /dev/null +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { RecordShareRecipientType, SharingRuleRecipientType } from './sharing-service'; +import { ShareRecipientType } from '../security/sharing.zod'; + +/** + * [#4539] `RecordShareRecipientType` (né `ShareRecipientType`) pins. + * + * The contract type used to be named `ShareRecipientType` while + * `spec/security` exported a zod enum `ShareRecipientType` for a DIFFERENT + * concept (sharing-RULE recipients) with a diverged value set — the #4411 + * dual-source trap, worsened by the type≠const kind split. The contract side + * was renamed; these pins keep the two vocabularies honest about what they + * each describe. + */ +describe('Sharing Service Contract — recipient vocabularies (#4539)', () => { + it('RecordShareRecipientType matches the sys_record_share recipient_type select', () => { + // The storage select on SysRecordShare (`@objectstack/plugin-sharing`) is + // the gate on what a row can contain; the contract type mirrors it 1:1. + // `role` — the value the old contract type carried — was never + // persistable there and is NOT a member. + const rowRecipients: RecordShareRecipientType[] = [ + 'user', + 'group', + 'position', + 'unit_and_subordinates', + 'guest', + ]; + // @ts-expect-error `role` never was a persistable recipient_type value + const notARowRecipient: RecordShareRecipientType = 'role'; + expect(rowRecipients).toHaveLength(5); + expect(notARowRecipient).toBe('role'); + }); + + it('security ShareRecipientType is the RULE vocabulary and shares no declaration', () => { + // The authorable rule-recipient enum: SharingRuleRecipientType minus the + // reserved `queue`. Distinct concept, distinct values — `group` / `guest` + // were deliberately removed from it (ADR-0078) while the record-share ROW + // vocabulary keeps persisting them for forward compatibility. + expect(ShareRecipientType.options).toEqual([ + 'user', + 'team', + 'position', + 'unit_and_subordinates', + 'business_unit', + ]); + const ruleRecipient: SharingRuleRecipientType = 'queue'; + // @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable + const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue'; + expect(ruleRecipient).toBe(notAuthorable); + }); +}); diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 3c117845e1..c25ff95589 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -23,11 +23,25 @@ * The default implementation lives in `@objectstack/plugin-sharing`. */ -/** Recipient categories — mirrors `ShareRecipientType` in spec/security. */ -export type ShareRecipientType = +/** + * Recipient categories a `sys_record_share` ROW may carry — mirrors the + * `recipient_type` select on `SysRecordShare` + * (`@objectstack/plugin-sharing`), which is the storage-side gate on what a + * row can actually contain. Only `user` is enforced (and written) today: + * {@link ISharingService.grant} refuses every other value rather than + * persisting it inert (ADR-0078), and the rule evaluator materialises grants + * as expanded `user` rows. The remaining members are persisted-for-forward- + * compatibility vocabulary, kept in lockstep with the select. + * + * NOT the sharing-RULE recipient vocabulary — that is + * {@link SharingRuleRecipientType} here, whose authorable subset is + * `ShareRecipientType` in `spec/security` (a different concept that shares + * no declaration with this one). + */ +export type RecordShareRecipientType = | 'user' | 'group' - | 'role' + | 'position' | 'unit_and_subordinates' | 'guest'; @@ -52,7 +66,7 @@ export interface RecordShare { id: string; object_name: string; record_id: string; - recipient_type: ShareRecipientType; + recipient_type: RecordShareRecipientType; recipient_id: string; access_level: ShareAccessLevel; source: ShareSource; @@ -67,7 +81,7 @@ export interface RecordShare { export interface GrantShareInput { object: string; recordId: string; - recipientType?: ShareRecipientType; + recipientType?: RecordShareRecipientType; recipientId: string; accessLevel?: ShareAccessLevel; source?: ShareSource; diff --git a/packages/spec/src/data/type-compat.test.ts b/packages/spec/src/data/type-compat.test.ts index 7a0209f45b..c6028a7807 100644 --- a/packages/spec/src/data/type-compat.test.ts +++ b/packages/spec/src/data/type-compat.test.ts @@ -3,9 +3,10 @@ import { describe, it, expect } from 'vitest'; import { canonicalizeSqlType, - suggestFieldType, + suggestFieldTypeForSqlType, isCompatible, } from './type-compat'; +import { suggestFieldType } from '../shared/suggestions.zod'; describe('canonicalizeSqlType (ADR-0015 §4.6)', () => { it('strips length/precision parameters', () => { @@ -45,20 +46,39 @@ describe('canonicalizeSqlType (ADR-0015 §4.6)', () => { }); }); -describe('suggestFieldType', () => { +describe('suggestFieldTypeForSqlType', () => { it('suggests sensible defaults per canonical type', () => { - expect(suggestFieldType('varchar(255)')).toBe('text'); - expect(suggestFieldType('integer')).toBe('number'); - expect(suggestFieldType('numeric(10,2)')).toBe('number'); - expect(suggestFieldType('boolean')).toBe('boolean'); - expect(suggestFieldType('timestamptz', 'postgres')).toBe('datetime'); - expect(suggestFieldType('date')).toBe('date'); - expect(suggestFieldType('jsonb', 'postgres')).toBe('json'); - expect(suggestFieldType('vector', 'postgres')).toBe('vector'); + expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text'); + expect(suggestFieldTypeForSqlType('integer')).toBe('number'); + expect(suggestFieldTypeForSqlType('numeric(10,2)')).toBe('number'); + expect(suggestFieldTypeForSqlType('boolean')).toBe('boolean'); + expect(suggestFieldTypeForSqlType('timestamptz', 'postgres')).toBe('datetime'); + expect(suggestFieldTypeForSqlType('date')).toBe('date'); + expect(suggestFieldTypeForSqlType('jsonb', 'postgres')).toBe('json'); + expect(suggestFieldTypeForSqlType('vector', 'postgres')).toBe('vector'); }); it('returns undefined for unknown types', () => { - expect(suggestFieldType('geometry')).toBeUndefined(); + expect(suggestFieldTypeForSqlType('geometry')).toBeUndefined(); + }); + + // #4539: this mapper and shared/suggestions.zod's `suggestFieldType` used to + // SHARE the name `suggestFieldType` while being different functions with + // different signatures, semantics and return types — the worst dual-source + // shape, since a wrong auto-import compiled (`[]` is truthy where + // `undefined` was expected) and misbehaved with no type error. These pins + // encode the divergence that forced the rename; if the two are ever + // reconciled, delete this block deliberately. + it('is NOT the typo-suggester: same input, divergent semantics', () => { + // SQL vocabulary: mapper resolves it, typo-suggester cannot. + expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text'); + expect(suggestFieldType('varchar(255)')).toEqual([]); + // FieldType typo: typo-suggester resolves it, mapper cannot. + expect(suggestFieldTypeForSqlType('text_area')).toBeUndefined(); + expect(suggestFieldType('text_area')).toEqual(['textarea']); + // Overlapping input: scalar FieldType vs array of candidates. + expect(suggestFieldTypeForSqlType('int')).toBe('number'); + expect(suggestFieldType('int')).toEqual(['number']); }); }); diff --git a/packages/spec/src/data/type-compat.ts b/packages/spec/src/data/type-compat.ts index d98e4e3e7a..240b99cfc2 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -8,10 +8,17 @@ * ObjectStack field types, and answering "can this remote column back this * field type?". Used by `IExternalDatasourceService`: * - * - `generateObjectDraft` → {@link suggestFieldType} to draft `*.object.ts`. + * - `generateObjectDraft` → {@link suggestFieldTypeForSqlType} to draft + * `*.object.ts`. * - `validateObject` → {@link isCompatible} to diff a declared field * against the remote column. * + * Naming: NOT `suggestFieldType` — that name belongs to the typo-correction + * helper in `shared/suggestions.zod.ts` (invalid authored FieldType → + * `string[]` of candidates, exported from the root entry). This one is the + * deterministic SQL-column → FieldType mapper; the two sharing one name was + * the #4411 dual-source trap (#4539). + * * No I/O, no driver coupling — operates on raw type strings so it can be * unit-tested independently and extended per dialect without touching the * runtime. @@ -194,7 +201,7 @@ export function canonicalizeSqlType(rawType: string, dialect?: SqlDialect): Cano * Returns `undefined` only when the type is wholly unrecognised (the caller * may fall back to `text` and flag it for review). */ -export function suggestFieldType(rawType: string, dialect?: SqlDialect): FieldType | undefined { +export function suggestFieldTypeForSqlType(rawType: string, dialect?: SqlDialect): FieldType | undefined { const canonical = canonicalizeSqlType(rawType, dialect); if (canonical === 'unknown') return undefined; return CANONICAL_TO_FIELD[canonical].suggested; diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts index 4f6d4cf2dd..ec1004d1b7 100644 --- a/packages/spec/src/shared/mapping.test.ts +++ b/packages/spec/src/shared/mapping.test.ts @@ -1,32 +1,32 @@ import { describe, it, expect } from 'vitest'; -import { TransformTypeSchema, FieldMappingSchema } from './mapping.zod'; +import { FieldMappingTransformSchema, FieldMappingSchema } from './mapping.zod'; -describe('TransformTypeSchema', () => { +describe('FieldMappingTransformSchema', () => { it('should accept constant transform', () => { - const result = TransformTypeSchema.parse({ type: 'constant', value: 'hello' }); + const result = FieldMappingTransformSchema.parse({ type: 'constant', value: 'hello' }); expect(result).toEqual({ type: 'constant', value: 'hello' }); }); it('should accept constant transform with any value type', () => { - expect(() => TransformTypeSchema.parse({ type: 'constant', value: 42 })).not.toThrow(); - expect(() => TransformTypeSchema.parse({ type: 'constant', value: null })).not.toThrow(); - expect(() => TransformTypeSchema.parse({ type: 'constant', value: true })).not.toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'constant', value: 42 })).not.toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'constant', value: null })).not.toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'constant', value: true })).not.toThrow(); }); it('should accept cast transform with valid target types', () => { const validTypes = ['string', 'number', 'boolean', 'date']; validTypes.forEach((t) => { - const result = TransformTypeSchema.parse({ type: 'cast', targetType: t }); + const result = FieldMappingTransformSchema.parse({ type: 'cast', targetType: t }); expect(result).toEqual({ type: 'cast', targetType: t }); }); }); it('should reject cast transform with invalid target type', () => { - expect(() => TransformTypeSchema.parse({ type: 'cast', targetType: 'array' })).toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'cast', targetType: 'array' })).toThrow(); }); it('should accept lookup transform', () => { - const result = TransformTypeSchema.parse({ + const result = FieldMappingTransformSchema.parse({ type: 'lookup', table: 'users', keyField: 'id', @@ -41,11 +41,11 @@ describe('TransformTypeSchema', () => { }); it('should reject lookup transform missing required fields', () => { - expect(() => TransformTypeSchema.parse({ type: 'lookup', table: 'users' })).toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'lookup', table: 'users' })).toThrow(); }); it('should accept javascript transform', () => { - const result = TransformTypeSchema.parse({ + const result = FieldMappingTransformSchema.parse({ type: 'javascript', expression: 'value.toUpperCase()', }); @@ -53,7 +53,7 @@ describe('TransformTypeSchema', () => { }); it('should accept map transform', () => { - const result = TransformTypeSchema.parse({ + const result = FieldMappingTransformSchema.parse({ type: 'map', mappings: { Active: 'active', Inactive: 'inactive' }, }); @@ -64,7 +64,7 @@ describe('TransformTypeSchema', () => { }); it('should reject unknown transform type', () => { - expect(() => TransformTypeSchema.parse({ type: 'unknown' })).toThrow(); + expect(() => FieldMappingTransformSchema.parse({ type: 'unknown' })).toThrow(); }); }); diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts index ef01101495..5ff9b77b9e 100644 --- a/packages/spec/src/shared/mapping.zod.ts +++ b/packages/spec/src/shared/mapping.zod.ts @@ -37,13 +37,19 @@ import { ExpressionInputSchema } from './expression.zod'; */ /** - * Transform Type Schema - * - * Defines the type of transformation to apply to a field value. + * Field Mapping Transform Schema + * + * Defines the transformation to apply to a field value during mapping. * Implementations can extend this for domain-specific transforms. + * + * Renamed from `TransformTypeSchema` (#4539): its inferred type exported as + * `TransformType`, colliding with the data domain's import-mapping enum of + * the same name under a DIFFERENT shape (config-object union vs string enum) + * — the #4411 dual-source trap. Neither old name had importers outside this + * module in framework/cloud/objectui, so the rename is a clean break. */ import { lazySchema } from './lazy-schema'; -export const TransformTypeSchema = lazySchema(() => z.discriminatedUnion('type', [ +export const FieldMappingTransformSchema = lazySchema(() => z.discriminatedUnion('type', [ z.object({ type: z.literal('constant'), value: z.unknown().describe('Constant value to use'), @@ -72,7 +78,7 @@ export const TransformTypeSchema = lazySchema(() => z.discriminatedUnion('type', }).describe('Map values using a dictionary'), ])); -export type TransformType = z.infer; +export type FieldMappingTransform = z.infer; /** * Field Mapping Schema @@ -107,7 +113,7 @@ export const FieldMappingSchema = lazySchema(() => z.object({ /** * Transformation to apply */ - transform: TransformTypeSchema.optional().describe('Transformation to apply'), + transform: FieldMappingTransformSchema.optional().describe('Transformation to apply'), /** * Default value if source is null/undefined