Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27917-type-compat-user-text.md
Original file line number Diff line number Diff line change
@@ -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`.
11 changes: 8 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down
35 changes: 35 additions & 0 deletions packages/spec/src/data/type-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/src/data/type-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
Loading