Skip to content

Commit f934a41

Browse files
fix(spec): a user field over a text column passes the type-compat check; ADR-0015 names the odata vocabulary (#28011)
epic:#27261#1 · marchtian · session_017ihL2dhraZ7BwSanQPGEAb <!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiH18zL8aJRKfkWPzKCnyMP2)_ Fixes #27917 Clause-②: yes (widening) Metadata-protocol: none **Fable review owed.** The review quota was out when this PR was opened. It stays draft until the independent full review, the contract review PASS on head and an authorized approval (Tier H: `docs/adr/**`). ## 维护者速读 - **改了什么:** 一是 ADR-0015 §4.6 的方言清单补上 `odata`,并指向 `DIALECT_ALIASES.odata`(代码在 #27880 已加)。二是 `user` 字段加入类型矩阵 `text` 行的 exact 集合:外部表里用文本列存平台用户 id 的 `user` 字段,启动校验不再报 `type_mismatch` 错误。整数行(`integer` / `bigint`)不加。 - **为什么:** 实测平台用户 id(`sys_user.id`)永远是字符串:better-auth 为每个用户生成 32 位字母数字串,引擎直写时 SQL 驱动生成 16 位 nanoid,既不是 UUID 也不是整数。文本列装得下,整数列装不下,所以只放宽 `text` 行。 - **风险与回滚:** 只放宽一个接受集合,之前通过的不会变成失败;回滚即 revert。 - **需要您做的:** 合并前批准(改了 ADR,Tier H)。 ## What changes - **`packages/spec/src/data/type-compat.ts`.** `user` joins the `text` row's exact set (it was already exact under `uuid`). A comment on the row records the measurement and why the integer rows stay closed while `lookup` / `master_detail` are open there: a `user` field's target is fixed to `sys_user`, so the column holds a platform user id, whose shape is the platform's and not the remote system's. - **`docs/adr/0015-external-datasource-federation.md`.** §4.6 lists `odata` among the dialects, with one sentence naming the two non-SQL vocabularies: `DIALECT_ALIASES.mongo` (BSON type names) and `DIALECT_ALIASES.odata` (EDM primitive types, except `Edm.Duration` and the geography / geometry families, left unmapped for review). The `Amended by` line records it as a dialect-list-only amendment. - **`packages/spec/src/data/type-compat.test.ts`.** One case pins the decision both ways: `user` is exact over `varchar(255)`, `text`, postgres `citext`, bigquery `STRING`, odata `Edm.String` (and still over `uuid` / `Edm.Guid`); it is refused over `integer`, `bigint`, postgres `int8`, `Edm.Int32`, `Edm.Int64`, `boolean` and `jsonb`, while `lookup` stays exact over the same integer columns. A second case pins that the draft still suggests `text` for a text column (the suggestion is read from the column alone). - **`.changeset/27917-type-compat-user-text.md`.** `@objectstack/spec` patch, with both declaration lines. ## Why ### The measurement (`sys_user.id`) - **better-auth 1.7.12, the version `@objectstack/plugin-auth` pins.** Five `auth.api.signUpEmail` calls through a `createAdapterFactory` adapter configured as `objectql-adapter.ts` configures it (no `advanced.database.generateId`) wrote five `user` rows. Every id was 32 characters, all alphanumeric, not all-digit, not UUID-shaped (for example `jaS4bSf4BGL50JWp7UaSlvZFEEAKHKiE`). Admin create and the user import go through `auth.api.createUser`, the same id path. - **The SQL driver.** A row written through the engine without an id gets `nanoid(DEFAULT_ID_LENGTH)` with `DEFAULT_ID_LENGTH = 16` (`packages/drivers/driver-sql/src/sql-driver.ts`), a string; a caller-supplied id is stored verbatim. - **Decision.** A text column carries every one of those, so `user` is exact under `text`. No path produces an integer user id, so `user` stays refused under `integer` / `bigint`. This follows the card's own condition ("the integer rows only if a platform user id can be an integer"). ### What an author can declare The `user` field's own schema is unchanged. The only reader of the matrix that refuses is Gate 2 (`validateObject` in `service-datasource`): a federated object with a `user` field over a text column used to get a `type_mismatch` **error** (boot fails under `onMismatch: 'fail'`) and now passes. Nothing that passed before fails now. The draft generator never suggests `user` (suggestion is per column and stays `text`), so imports are unchanged. ## Verification - `pnpm --filter @objectstack/spec exec vitest run src/data/type-compat.test.ts`: 29 passed. - Reverse check from the committed fix: removing `'user'` from the `text` row alone fails the new case at the `varchar(255)` assertion (`expected false to be true`, line 190); restored with `git checkout HEAD --`. - `dispatch-gates --commands` derived 85 families on `ecf8b5c4`; all 85 run with exit codes recorded before any pipe. Four exited 3 on the first pass (not measured: plugin-teardown self-test fixture, doc-formula-expressions, dual-build-cjs-loads, lean-entry-closure) and exit 0 after fetching the fixture commit and building `./packages/**`. `--ran`: 85 derived, 85 run, 0 NOT-MEASURED (a derived zero). - `pnpm --filter @objectstack/spec typecheck`: exit 0 (test-typecheck ledger held). `vitest run src/data/` in spec: 149 files, 4588 passed. ## Contract review 6518256754 (FAIL on `ecf8b5c4`), fixed in `10deba35`; delta 6518728501 PASS - **Must-fix, ADR §4.6.** The added sentence called `odata` the one vocabulary that is not SQL; `mongo` maps BSON type names too, as the `SqlDialect` doc comment says. The sentence now names both, pointing at `DIALECT_ALIASES.mongo` and `DIALECT_ALIASES.odata`. - **Should-fix, same sentence.** It now says the odata arm leaves `Edm.Duration` and the `Edm.Geography*` / `Edm.Geometry*` families unmapped so a draft flags them for review. - **Should-fix, `type-compat.ts` row comment.** "Never a UUID" held for minted ids only. The comment now says a minted id is never a UUID or an integer, and a caller-supplied id is stored verbatim as a string, which is why the `uuid` row keeps `user`. - **PR body.** The opening paragraph no longer names a model beyond the card's own "Fable review owed" marker. - The review's ③ also closed the memory and Mongo driver note below: `driver-memory` mints an `objectName-timestamp-counter` string and `driver-mongodb` a 16-character nanoid. Delta review 6518728501 by the same reviewer: PASS on `10deba35`, every finding closed. Re-run in full on `10deba35`: `dispatch-gates --commands` derived the same 85 families; all 85 run with exit codes recorded. Five spec gates (api-surface, browser-reachable-entries, dual-source-exports, entry-nameability, exported-any) exited 3 on the stale spec `dist/` and exit 0 after `pnpm --filter @objectstack/spec build`. `--ran`: 85 derived, 85 run, 0 NOT-MEASURED (a derived zero). ## Acceptance notes - **Integer rows.** Left closed by measurement, not by caution. If a deployment ever configures better-auth with numeric ids, that is a new id shape and its own card. - **Memory and Mongo drivers** mint their own ids for engine-direct writes, both strings (read by the contract review, not measured here). Every user the platform creates goes through better-auth, which mints the id before the driver sees the row. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (A matrix entry and its test only.) - [x] 2. Every name I added uses the casing for its kind. (No new names.) - [x] 3. No formatter ran (`pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: the type-compat matrix and its test, no schema. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a37823 commit f934a41

4 files changed

Lines changed: 67 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): a `user` field over a text column passes the external-datasource type check; ADR-0015 names the `odata` vocabulary
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **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.
11+
- **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.
12+
- **ADR-0015 §4.6** lists `odata` among the dialects the matrix knows, pointing at `DIALECT_ALIASES.odata`.

‎docs/adr/0015-external-datasource-federation.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type)
66
**Consumers**: `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugins/driver-sql`, `@objectstack/services/service-ai`, `@objectstack/cli`, `../objectui`
7-
**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.
7+
**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.
88

99
> **Amendment note D7 (2026-10-29, ruling 6486136301, letter A) — a second credential channel.**
1010
> §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
410410
(`text`, `varchar`, `numeric(10,2)`, `timestamptz`, `jsonb`, …) to
411411
ObjectStack field types, and exposes `isCompatible(remoteSqlType,
412412
fieldType): boolean | 'lossy'`. The matrix is dialect-aware
413-
(`postgres`, `mysql`, `sqlite`, `snowflake`, `bigquery`, `mongo`). New
414-
dialects extend the matrix; the matrix is unit-tested independently.
413+
(`postgres`, `mysql`, `sqlite`, `snowflake`, `bigquery`, `mongo`, `odata`). New
414+
dialects extend the matrix; the matrix is unit-tested independently. `mongo` and
415+
`odata` are the two vocabularies that are not SQL: `DIALECT_ALIASES.mongo` maps
416+
BSON type names, and `DIALECT_ALIASES.odata` maps the EDM primitive types an
417+
OData service's `$metadata` declares, except `Edm.Duration` and the
418+
`Edm.Geography*` / `Edm.Geometry*` families, which it leaves unmapped so a draft
419+
flags them for review.
415420

416421
---
417422

‎packages/spec/src/data/type-compat.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,41 @@ describe('isCompatible', () => {
181181
}
182182
});
183183

184+
// A `user` field's target is fixed to `sys_user`, so the column holds a
185+
// platform user id: a string (better-auth mints 32 alphanumeric characters,
186+
// the SQL driver 16-character nanoids), never an integer. A text column backs
187+
// it exactly; an integer column cannot hold one, so it stays refused there,
188+
// even though `lookup` is exact on the same rows.
189+
it('accepts a text column for a user field and refuses an integer one', () => {
190+
expect(isCompatible('varchar(255)', 'user')).toBe(true);
191+
expect(isCompatible('text', 'user')).toBe(true);
192+
expect(isCompatible('citext', 'user', 'postgres')).toBe(true);
193+
expect(isCompatible('STRING', 'user', 'bigquery')).toBe(true);
194+
expect(isCompatible('Edm.String', 'user', 'odata')).toBe(true);
195+
// The pre-existing exact row, unchanged.
196+
expect(isCompatible('uuid', 'user')).toBe(true);
197+
expect(isCompatible('Edm.Guid', 'user', 'odata')).toBe(true);
198+
// A platform user id is never an integer.
199+
expect(isCompatible('integer', 'user')).toBe(false);
200+
expect(isCompatible('bigint', 'user')).toBe(false);
201+
expect(isCompatible('int8', 'user', 'postgres')).toBe(false);
202+
expect(isCompatible('Edm.Int32', 'user', 'odata')).toBe(false);
203+
expect(isCompatible('Edm.Int64', 'user', 'odata')).toBe(false);
204+
// The same integer columns back a lookup: the difference is the target.
205+
expect(isCompatible('integer', 'lookup')).toBe(true);
206+
expect(isCompatible('Edm.Int32', 'lookup', 'odata')).toBe(true);
207+
// Not key-shaped at all.
208+
expect(isCompatible('boolean', 'user')).toBe(false);
209+
expect(isCompatible('jsonb', 'user', 'postgres')).toBe(false);
210+
});
211+
212+
it('does not suggest a user field for a text column', () => {
213+
// The draft suggests from the column alone and a text column says nothing
214+
// about holding user ids, so the suggestion stays `text`.
215+
expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text');
216+
expect(suggestFieldTypeForSqlType('Edm.String', 'odata')).toBe('text');
217+
});
218+
184219
it('returns "lossy" for usable-but-imperfect mappings', () => {
185220
expect(isCompatible('jsonb', 'text', 'postgres')).toBe('lossy');
186221
expect(isCompatible('date', 'datetime')).toBe('lossy');

‎packages/spec/src/data/type-compat.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,18 @@ const CANONICAL_TO_FIELD: Record<
182182
// `ReferentialConstraint` over `Edm.Int32`). Without them Gate 2 refused, as
183183
// a `type_mismatch` error, every lookup over a foreign key that is not a
184184
// UUID.
185-
text: { suggested: 'text', exact: ['text', 'textarea', 'email', 'url', 'phone', 'markdown', 'html', 'richtext', 'code', 'select', 'color', 'signature', 'qrcode', 'lookup', 'master_detail'], lossy: [] },
185+
//
186+
// `user` is exact under `text` but NOT under `integer` / `bigint`, unlike
187+
// the two reference types above: its target is fixed to `sys_user`, so the
188+
// column holds a PLATFORM user id, whose shape is the platform's and never
189+
// the remote system's. Measured: better-auth mints a 32-character
190+
// alphanumeric string for every user it creates (sign-up, admin create,
191+
// import), and a row written past it through the engine gets the SQL
192+
// driver's 16-character nanoid. A minted id is never a UUID or an integer;
193+
// a caller-supplied id is stored verbatim as a string, which is why the
194+
// `uuid` row keeps `user`. A text column carries it; an integer column
195+
// cannot hold one, so `user` there stays a `type_mismatch` error.
196+
text: { suggested: 'text', exact: ['text', 'textarea', 'email', 'url', 'phone', 'markdown', 'html', 'richtext', 'code', 'select', 'color', 'signature', 'qrcode', 'lookup', 'master_detail', 'user'], lossy: [] },
186197
integer: { suggested: 'number', exact: ['number', 'autonumber', 'rating', 'percent', 'lookup', 'master_detail'], lossy: ['currency', 'boolean'] },
187198
bigint: { suggested: 'number', exact: ['number', 'autonumber', 'lookup', 'master_detail'], lossy: ['currency'] },
188199
decimal: { suggested: 'number', exact: ['number', 'currency', 'percent'], lossy: ['rating'] },

0 commit comments

Comments
 (0)