diff --git a/.changeset/27917-type-compat-user-text.md b/.changeset/27917-type-compat-user-text.md new file mode 100644 index 00000000000..7638f66132f --- /dev/null +++ b/.changeset/27917-type-compat-user-text.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a `user` field over a text column passes the external-datasource type check; ADR-0015 names the `odata` vocabulary + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Type matrix (`@objectstack/spec/data`).** `user` is now exact under the `text` row, as it already was under `uuid`. A `user` field's target is fixed to `sys_user`, so its column holds a platform user id, which is a string: better-auth mints 32 alphanumeric characters, and the SQL driver mints a 16-character nanoid for a row the engine writes without one. Before, `isCompatible` refused a `user` field over a `varchar` / `Edm.String` column as a `type_mismatch` error, so the boot validation (`onMismatch: 'fail'`) refused a federated object that declared one. Nothing that passed before fails now. +- **Integer rows unchanged.** A platform user id is never an integer, so `user` stays refused under `integer` / `bigint` (`Edm.Int32`, `Edm.Int64`), even though `lookup` and `master_detail` are exact there. +- **ADR-0015 §4.6** lists `odata` among the dialects the matrix knows, pointing at `DIALECT_ALIASES.odata`. diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index 25290c1a778..c60f78363ea 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -4,7 +4,7 @@ **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type) **Consumers**: `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugins/driver-sql`, `@objectstack/services/service-ai`, `@objectstack/cli`, `../objectui` -**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. D7 (2026-10-29, ruling 6486136301 on #27298) — an API-backed datasource may borrow a declared connector instance's credential through `external.connector`; see the D7 note below. #27688 (2026-10-30), example only — §8's `credentialsRef` is a `sys_secret:` handle, the only form the shipped secret binder resolves; see the note under §8. +**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. D7 (2026-10-29, ruling 6486136301 on #27298) — an API-backed datasource may borrow a declared connector instance's credential through `external.connector`; see the D7 note below. #27688 (2026-10-30), example only — §8's `credentialsRef` is a `sys_secret:` handle, the only form the shipped secret binder resolves; see the note under §8. #27917 (2026-10-30), dialect list only — §4.6 names the `odata` vocabulary the matrix gained with #27745. > **Amendment note D7 (2026-10-29, ruling 6486136301, letter A) — a second credential channel.** > §10 says credentials reach a datasource only through `credentialsRef`. That stays true for a @@ -410,8 +410,13 @@ A pure module `packages/spec/src/data/type-compat.ts` maps SQL types (`text`, `varchar`, `numeric(10,2)`, `timestamptz`, `jsonb`, …) to ObjectStack field types, and exposes `isCompatible(remoteSqlType, fieldType): boolean | 'lossy'`. The matrix is dialect-aware -(`postgres`, `mysql`, `sqlite`, `snowflake`, `bigquery`, `mongo`). New -dialects extend the matrix; the matrix is unit-tested independently. +(`postgres`, `mysql`, `sqlite`, `snowflake`, `bigquery`, `mongo`, `odata`). New +dialects extend the matrix; the matrix is unit-tested independently. `mongo` and +`odata` are the two vocabularies that are not SQL: `DIALECT_ALIASES.mongo` maps +BSON type names, and `DIALECT_ALIASES.odata` maps the EDM primitive types an +OData service's `$metadata` declares, except `Edm.Duration` and the +`Edm.Geography*` / `Edm.Geometry*` families, which it leaves unmapped so a draft +flags them for review. --- diff --git a/packages/spec/src/data/type-compat.test.ts b/packages/spec/src/data/type-compat.test.ts index 8a0d9502f53..3a6a2bd8b91 100644 --- a/packages/spec/src/data/type-compat.test.ts +++ b/packages/spec/src/data/type-compat.test.ts @@ -181,6 +181,41 @@ describe('isCompatible', () => { } }); + // A `user` field's target is fixed to `sys_user`, so the column holds a + // platform user id: a string (better-auth mints 32 alphanumeric characters, + // the SQL driver 16-character nanoids), never an integer. A text column backs + // it exactly; an integer column cannot hold one, so it stays refused there, + // even though `lookup` is exact on the same rows. + it('accepts a text column for a user field and refuses an integer one', () => { + expect(isCompatible('varchar(255)', 'user')).toBe(true); + expect(isCompatible('text', 'user')).toBe(true); + expect(isCompatible('citext', 'user', 'postgres')).toBe(true); + expect(isCompatible('STRING', 'user', 'bigquery')).toBe(true); + expect(isCompatible('Edm.String', 'user', 'odata')).toBe(true); + // The pre-existing exact row, unchanged. + expect(isCompatible('uuid', 'user')).toBe(true); + expect(isCompatible('Edm.Guid', 'user', 'odata')).toBe(true); + // A platform user id is never an integer. + expect(isCompatible('integer', 'user')).toBe(false); + expect(isCompatible('bigint', 'user')).toBe(false); + expect(isCompatible('int8', 'user', 'postgres')).toBe(false); + expect(isCompatible('Edm.Int32', 'user', 'odata')).toBe(false); + expect(isCompatible('Edm.Int64', 'user', 'odata')).toBe(false); + // The same integer columns back a lookup: the difference is the target. + expect(isCompatible('integer', 'lookup')).toBe(true); + expect(isCompatible('Edm.Int32', 'lookup', 'odata')).toBe(true); + // Not key-shaped at all. + expect(isCompatible('boolean', 'user')).toBe(false); + expect(isCompatible('jsonb', 'user', 'postgres')).toBe(false); + }); + + it('does not suggest a user field for a text column', () => { + // The draft suggests from the column alone and a text column says nothing + // about holding user ids, so the suggestion stays `text`. + expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text'); + expect(suggestFieldTypeForSqlType('Edm.String', 'odata')).toBe('text'); + }); + it('returns "lossy" for usable-but-imperfect mappings', () => { expect(isCompatible('jsonb', 'text', 'postgres')).toBe('lossy'); expect(isCompatible('date', 'datetime')).toBe('lossy'); diff --git a/packages/spec/src/data/type-compat.ts b/packages/spec/src/data/type-compat.ts index ea75c2c9d9d..008464e9dcb 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -182,7 +182,18 @@ const CANONICAL_TO_FIELD: Record< // `ReferentialConstraint` over `Edm.Int32`). Without them Gate 2 refused, as // a `type_mismatch` error, every lookup over a foreign key that is not a // UUID. - text: { suggested: 'text', exact: ['text', 'textarea', 'email', 'url', 'phone', 'markdown', 'html', 'richtext', 'code', 'select', 'color', 'signature', 'qrcode', 'lookup', 'master_detail'], lossy: [] }, + // + // `user` is exact under `text` but NOT under `integer` / `bigint`, unlike + // the two reference types above: its target is fixed to `sys_user`, so the + // column holds a PLATFORM user id, whose shape is the platform's and never + // the remote system's. Measured: better-auth mints a 32-character + // alphanumeric string for every user it creates (sign-up, admin create, + // import), and a row written past it through the engine gets the SQL + // driver's 16-character nanoid. A minted id is never a UUID or an integer; + // a caller-supplied id is stored verbatim as a string, which is why the + // `uuid` row keeps `user`. A text column carries it; an integer column + // cannot hold one, so `user` there stays a `type_mismatch` error. + text: { suggested: 'text', exact: ['text', 'textarea', 'email', 'url', 'phone', 'markdown', 'html', 'richtext', 'code', 'select', 'color', 'signature', 'qrcode', 'lookup', 'master_detail', 'user'], lossy: [] }, integer: { suggested: 'number', exact: ['number', 'autonumber', 'rating', 'percent', 'lookup', 'master_detail'], lossy: ['currency', 'boolean'] }, bigint: { suggested: 'number', exact: ['number', 'autonumber', 'lookup', 'master_detail'], lossy: ['currency'] }, decimal: { suggested: 'number', exact: ['number', 'currency', 'percent'], lossy: ['rating'] },