Skip to content

Commit f6344e7

Browse files
os-warrenclaude
andauthored
fix(platform-objects): make the last two non-unique keyed text indexes expressible on MySQL (#12314)
A non-unique index over a text column MySQL cannot key is refused outright (ER_BLOB_KEY_WITHOUT_LENGTH), failing the whole object's syncSchema and leaving it registered with its declared index absent. #11627's hash shadow closed the UNIQUE half of this class; it structurally cannot serve the non-unique half, because an index over a digest accelerates no WHERE col = ? the planner can reach. Two members remained, ruled separately by the maintainer on 2026-08-25: - sys_verification.value: the declared index is REMOVED. The column is genuinely unboundable (oauth-provider writes OIDC authorization-code payloads there as a JSON blob) and the index was measured dead — better-auth 1.7.1 keys verification lookups on identifier/id/expiresAt, upstream declares the field unindexed and unbounded, and no in-repo query filters by value. - sys_oauth_client_resource.resource_id: the declared bound narrows 1024 -> 768. This is a live access path (FK side of sys_oauth_resource.identifier), so it keeps its index and becomes keyable instead. Narrowing below the referent's 1024 is safe because upstream better-auth stores that same identifier as varchar(255) on MySQL and this referring column as varchar(36), so nothing the producing contract can emit lives in the discarded band. The package pin gains the executable form of "the class is closed": it now rejects a non-unique index over any text column MySQL cannot key, enumerated across the whole package rather than naming these two. Its UNBOUNDABLE allowlist, which existed to excuse sys_verification.value, moves with the change and is empty; a synthetic control keeps its excusing branch exercised. Part of #11701 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b10068 commit f6344e7

4 files changed

Lines changed: 296 additions & 33 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
Make the last two non-unique keyed text indexes expressible on MySQL — remove one,
6+
narrow one
7+
8+
`driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the
9+
declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part
10+
ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key
11+
(`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands
12+
registered with its declared index absent. #11374 declared sourced bounds for thirteen
13+
such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow
14+
column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects.
15+
16+
The two that remained are **non-unique**, and a hash shadow structurally cannot serve
17+
them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly,
18+
but a non-unique index exists for an access path, and an index over a digest
19+
accelerates no `WHERE col = ?` the planner can reach without rewriting the read side.
20+
They are ruled separately (maintainer, 2026-08-25) because they are different problems:
21+
22+
- **`sys_verification.value` — the declared index is removed.** The column is
23+
genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code
24+
payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1
25+
keys every verification lookup on `identifier`, `id` or `expiresAt`
26+
(`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no
27+
in-repo query filters `sys_verification` by `value`. An index that silently does not
28+
exist on one dialect is the worst of both worlds; removing it makes the metadata match
29+
reality.
30+
- **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.**
31+
This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read
32+
as a predicate by upstream's client-registration collision path), so it keeps its
33+
index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part
34+
holds, and the smallest narrowing that works.
35+
36+
This is an enforcement change on published objects — hence the minor grade. On MySQL and
37+
SQL Server a `resource_id` longer than 768 characters is now refused rather than stored,
38+
and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the
39+
next schema sync (on MySQL it never existed). Neither narrows what the producing
40+
contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream
41+
better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL
42+
(`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose
43+
identifier exceeded 768 characters could never have been registered upstream at all.
44+
45+
The pin that enumerated the package for unbounded keyed text columns now also rejects a
46+
non-unique index over any text column MySQL cannot key, so a third member of the class
47+
fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which
48+
existed to excuse `sys_verification.value` — is empty as a result, and a synthetic
49+
control keeps the excusing branch exercised rather than letting it rot.

packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,35 @@ export const SysOauthClientResource = ObjectSchema.create({
5252
resource_id: Field.text({
5353
label: 'Resource ID',
5454
required: true,
55-
maxLength: 1024,
55+
// [#11701] Narrowed 1024 → 768 so the declared `[resource_id]` index can
56+
// exist at all. At 1024 the column stays TEXT on MySQL and the index is
57+
// refused (`ER_BLOB_KEY_WITHOUT_LENGTH`), taking the whole object's
58+
// schema-sync down with it; 768 characters is the widest utf8mb4 value a
59+
// MySQL key part can hold (768 × 4 = 3072 bytes, exactly the ceiling).
60+
//
61+
// ⚠️ This is the one bound in the family that does NOT simply take its
62+
// referenced column's width: `sys_oauth_resource.identifier` declares
63+
// 1024. Narrowing below the referent is safe here because the
64+
// (768, 1024] band holds nothing the PRODUCING contract can emit. The
65+
// value is an RFC 8707 resource-indicator URI, and upstream better-auth
66+
// 1.7.1 — the sole writer of this table (`managedBy: 'better-auth'`) —
67+
// stores that same identifier in `oauthResource.identifier` as
68+
// **varchar(255)** on MySQL (`better-auth/dist/db/get-migration.mjs`,
69+
// `getType`: a unique string column → varchar(255)) and this referring
70+
// column as varchar(36) (its `field.references` branch). A resource
71+
// whose identifier exceeded 768 characters could never have been
72+
// registered upstream in the first place.
73+
//
74+
// 768 rather than upstream's 255 on purpose: it is the SMALLEST
75+
// narrowing that makes the index expressible, so it rejects the least of
76+
// the referent's declared domain. Guessing a tighter number to make a
77+
// key fit is what `sys_account.issuer` refuses to do.
78+
//
79+
// ⛔ Unlike `sys_verification.value`, this index is NOT removable: this
80+
// is the FK side of `sys_oauth_resource.identifier` and upstream reads it
81+
// as a predicate (`findOne({ clientId, resourceId })` on the client
82+
// registration collision path), so it is a live access path.
83+
maxLength: 768,
5684
description: 'Foreign key to sys_oauth_resource.identifier',
5785
}),
5886

packages/platform-objects/src/identity/sys-verification.object.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export const SysVerification = ObjectSchema.create({
5757
value: Field.text({
5858
label: 'Verification Token',
5959
required: true,
60+
// [#11374/#11701] Deliberately UNBOUNDED: better-auth's oauth-provider
61+
// writes OIDC authorization-code payloads here as a JSON blob, so no
62+
// bound provably admits every value it may write. That is only
63+
// survivable because the column carries no index — see the `indexes`
64+
// note below, which is what makes an unbounded TEXT column safe on
65+
// MySQL.
6066
description: 'Token or code for verification',
6167
}),
6268

@@ -81,13 +87,34 @@ export const SysVerification = ObjectSchema.create({
8187
},
8288

8389
indexes: [
84-
// `value` must NOT be unique. better-auth's oauth-provider stores OIDC
85-
// authorization codes in this table with `value` = a JSON blob keyed by
86-
// user+client+state, which can legitimately repeat. A UNIQUE constraint
87-
// makes `/api/v1/auth/oauth2/authorize` fail (`UNIQUE constraint failed:
88-
// sys_verification.value`) → 503, breaking cloud-as-IdP SSO entirely.
89-
// better-auth keys verification lookups on `identifier`, not `value`.
90-
{ fields: ['value'], unique: false },
90+
// [#11701] `value` carries NO index — and must not gain one.
91+
//
92+
// Removing the index it used to declare is the maintainer's 2026-08-25
93+
// ruling, taken on MEASURED liveness rather than on convenience:
94+
//
95+
// • better-auth 1.7.1 keys every verification lookup on `identifier`
96+
// (or on `id`, or on `expiresAt` for cleanup) — see
97+
// `internal-adapter.mjs`'s `findByIdentifier` / `consumeByIdentifier`;
98+
// • upstream declares the field unindexed and unbounded;
99+
// • no in-repo query filters `sys_verification` by `value`.
100+
//
101+
// ⛔ It could not be indexed here even if a reader wanted it. `value` is
102+
// UNBOUNDABLE — better-auth's oauth-provider stores OIDC
103+
// authorization-code payloads in it as a JSON blob, so no defensible
104+
// `maxLength` exists — so on MySQL the column stays TEXT and ANY index
105+
// over it is refused (`ER_BLOB_KEY_WITHOUT_LENGTH`), failing the whole
106+
// object's schema-sync over an index nothing reads. #11627's hash-shadow
107+
// route cannot rescue it either: a shadow carries a UNIQUE constraint,
108+
// and an index over a digest accelerates no `WHERE value = ?` the planner
109+
// can reach. An index that silently does not exist on one dialect is the
110+
// worst of both worlds; removing it makes the metadata match reality,
111+
// which is the `declared = enforced` property this family restores.
112+
//
113+
// ⛔ A UNIQUE index here would be wrong twice over: those JSON payloads
114+
// legitimately repeat (they are keyed by user+client+state), and a unique
115+
// constraint made `/api/v1/auth/oauth2/authorize` fail (`UNIQUE
116+
// constraint failed: sys_verification.value`) → 503, breaking
117+
// cloud-as-IdP SSO entirely.
91118
{ fields: ['identifier'], unique: false },
92119
{ fields: ['expires_at'], unique: false },
93120
],

0 commit comments

Comments
 (0)