From ecf8b5c41d1e456f079d87b0d9542caa998ca7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:01:13 +0000 Subject: [PATCH 1/2] fix(spec): a user field over a text column passes the type-compat check; ADR-0015 names the odata vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `user` field's target is fixed to sys_user, so its column holds a platform user id. Measured: better-auth mints a 32-character alphanumeric string for every user it creates, and the SQL driver mints a 16-character nanoid for a row the engine writes without one, so the id is always a string and never an integer. `user` joins the text row's exact set; the integer and bigint rows keep refusing it, though lookup and master_detail are exact there. ADR-0015 §4.6 lists `odata` among the dialects the matrix knows. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --- .changeset/27917-type-compat-user-text.md | 12 +++++++ .../0015-external-datasource-federation.md | 8 +++-- packages/spec/src/data/type-compat.test.ts | 35 +++++++++++++++++++ packages/spec/src/data/type-compat.ts | 12 ++++++- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 .changeset/27917-type-compat-user-text.md 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..cee2228fa0f 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,10 @@ 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. `odata` is +the one vocabulary that is not SQL: its aliases (`DIALECT_ALIASES.odata`) map the +EDM primitive types an OData service's `$metadata` declares. --- 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..d31cbf04afe 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -182,7 +182,17 @@ 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 string either way, never a UUID and + // never an integer. 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'] }, From 10deba35c47816bc1126a05df54f018c13554da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:15:49 +0000 Subject: [PATCH 2/2] docs(adr-0015): mongo and odata are the matrix's two non-SQL vocabularies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on ecf8b5c4: §4.6 called `odata` the one vocabulary that is not SQL, but `mongo` maps BSON type names too, as the SqlDialect doc comment says. The sentence now names both, and says which EDM types the odata arm leaves unmapped for review. The text row's comment no longer says a user id is never a UUID: only a minted one is never; a caller-supplied id is stored verbatim, which is why the uuid row keeps `user`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --- docs/adr/0015-external-datasource-federation.md | 9 ++++++--- packages/spec/src/data/type-compat.ts | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index cee2228fa0f..c60f78363ea 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -411,9 +411,12 @@ A pure module `packages/spec/src/data/type-compat.ts` maps SQL types ObjectStack field types, and exposes `isCompatible(remoteSqlType, fieldType): boolean | 'lossy'`. The matrix is dialect-aware (`postgres`, `mysql`, `sqlite`, `snowflake`, `bigquery`, `mongo`, `odata`). New -dialects extend the matrix; the matrix is unit-tested independently. `odata` is -the one vocabulary that is not SQL: its aliases (`DIALECT_ALIASES.odata`) map the -EDM primitive types an OData service's `$metadata` declares. +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.ts b/packages/spec/src/data/type-compat.ts index d31cbf04afe..008464e9dcb 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -189,9 +189,10 @@ const CANONICAL_TO_FIELD: Record< // 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 string either way, never a UUID and - // never an integer. A text column carries it; an integer column cannot hold - // one, so `user` there stays a `type_mismatch` error. + // 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'] },