@@ -4,120 +4,47 @@ import { describe, it, expect } from 'vitest';
44import * as PlatformObjects from './index' ;
55
66/**
7- * #11374 — every text-family column a declared index keys on must declare a
8- * `maxLength`, because a bound is what lets the column be a key at all.
9- *
10- * ## Why this pin exists
11- *
12- * `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when
13- * the field declares a bound the dialect can key on, and leaves it `TEXT`
14- * otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length
15- * (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means:
16- * `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the
17- * object lands registered-but-broken with its declared uniqueness silently
18- * absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed
19- * schema-sync this way — sys_session and sys_account among them, so a MySQL
20- * stack could not sign anyone in.
21- *
22- * The driver deliberately does NOT substitute a prefix index: measured on the
23- * same server, a prefix-UNIQUE index is stricter-and-different — it refused a
24- * second, genuinely distinct token that shared its first 191 characters
25- * (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound
26- * has to live HERE, in the field declaration (maintainer ruling on #11374,
27- * 2026-08-24: route A).
28- *
29- * ## Why this file enumerates the WHOLE package, not just `identity/`
30- *
31- * It used to be `identity/identity-keyed-text-bounds.test.ts`, importing
32- * `./index` from `identity/`. That scoping is precisely how
33- * `sys_import_job.created_by` — a keyed, unbounded text column in `audit/` —
34- * survived route A's first pass: the pin could not see it, so nothing failed by
35- * name and the column was left for a follow-up card to find by hand. A pin that
36- * polices one directory does not police the defect class; it polices a
37- * directory. The enumeration now walks every object the package exports, and
38- * the vacuity control below asserts a column from OUTSIDE `identity/` is in
39- * the enumerated set, so the same narrowing cannot silently come back.
40- *
41- * ## What a red on this file means
42- *
43- * A new keyed text-family field arrived without a `maxLength`. Do not silence
44- * the assertion — derive a bound from the value's producer (upstream
45- * better-auth schema/constraints, IdP norms, or the in-repo producer) and
46- * declare it. If the value source genuinely cannot be bounded, extend
47- * `UNBOUNDABLE` WITH a comment naming why — but read the #11701 block below
48- * first: an unboundable column may only be keyed by a UNIQUE index, because a
49- * UNIQUE index is the only kind #11627's hash shadow can carry.
50- *
51- * A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling —
52- * e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 —
53- * `sys_oauth_resource.identifier` is no longer among them, see #12313): the
54- * column then stays TEXT and its index still cannot exist on MySQL directly.
55- * That debt was #11627's, and #11627 discharged it for the UNIQUE half — such
56- * an index is now carried on a hash-shadow column. The first `describe` below
57- * still polices only "keyed text declares its bound".
58- *
59- * ## #11701 — the NON-UNIQUE half, which a hash shadow cannot serve
60- *
61- * The second `describe` polices the case #11627 deliberately left refused. A
62- * UNIQUE constraint is an equality-only predicate, so hashing the value
63- * preserves it exactly; a NON-UNIQUE index exists for an ACCESS PATH, and an
64- * index over a digest accelerates no `WHERE col = ?` the planner can reach
65- * without rewriting the read side. So for a non-unique index there is no
66- * shadow to fall back on: the column must be KEYABLE — bounded, and bounded at
67- * or under 768 — or the index cannot exist on MySQL at all and the object's
68- * whole schema-sync is refused.
69- *
70- * That left exactly two platform members, and the maintainer ruled them
71- * separately on 2026-08-25 because they are different problems:
72- *
73- * • `sys_verification.value` — unboundable AND unread. The declared index was
74- * REMOVED, on measured liveness (better-auth keys verification lookups on
75- * `identifier`; no in-repo query filters by `value`). Removing it is what
76- * emptied `UNBOUNDABLE` below.
77- * • `sys_oauth_client_resource.resource_id` — a LIVE access path (the FK side
78- * of `sys_oauth_resource.identifier`), so its bound was narrowed
79- * 1024 → 768 instead. See the field's own comment for the evidence that
80- * nothing legitimate lives in the discarded band.
81- *
82- * ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked
83- * 768 as the smallest narrowing that made the index expressible and left
84- * the number unsourced on purpose; #12313 sourced the REFERENT
85- * (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's
86- * own varchar(255) emission) and this column follows it, as a referencing
87- * column takes the referenced column's bound. 255 ≤ 768, so the #11701
88- * rule below is still satisfied — it is the same disposition at a sourced
89- * number, not a different one.
90- *
91- * The pin below is the executable form of "the class is closed": it does not
92- * name those two, it enumerates the whole package, so a THIRD member arriving
93- * later fails here rather than being found on a live MySQL months on.
7+ * #11701 — a NON-UNIQUE declared index over a text column MySQL cannot key.
8+ *
9+ * ## What used to be here, and where it went (#12147)
10+ *
11+ * This file also carried route A's own rule — "every text-family column a
12+ * declared index keys on declares a `maxLength`" (#11374) — enumerated over
13+ * this package's exports, with a vacuity control, an `UNBOUNDABLE` allowlist
14+ * and a synthetic control driving that allowlist's two branches. All of it is
15+ * now `scripts/check-keyed-text-bounds.mjs`, which walks EVERY `*.object.ts` in
16+ * the repository rather than one package's export surface.
17+ *
18+ * That is not a like-for-like move, and the difference is the reason for it.
19+ * This pin enumerated `Object.values(PlatformObjects)`, so its population was
20+ * whatever the barrel re-exports — 95 keyed text columns, measured. The gate's
21+ * population over the same objects is 97: `sys_metadata_commit.package_id` and
22+ * `sys_metadata_commit.parent_commit_id` were invisible here, because
23+ * `metadata/index.ts` is a HAND-WRITTEN back-compat re-export naming four
24+ * objects and `sys_metadata_commit` was never added to it. Both columns are
25+ * bounded today, so nothing was broken — but nothing in the tree was watching
26+ * them either, which is the same escape-by-boundary this pin was itself widened
27+ * to close once before (`identity/` → the package, after
28+ * `sys_import_job.created_by` slipped through).
29+ *
30+ * ## Why THIS half stays
31+ *
32+ * It is a different rule with a different disposition, not a narrower copy of
33+ * the one that moved. Route A asks "is there a bound?"; this asks "is the
34+ * declared bound small enough to be a key?" — and answers it only for
35+ * NON-UNIQUE indexes, because a UNIQUE index over an unkeyable column is
36+ * EXPRESSIBLE after #11627 (it moves onto a SHA-256 hash-shadow column) while a
37+ * non-unique one is not: hashing destroys the ordering and prefix structure an
38+ * access path is for, so there is no fallback and the column itself must be
39+ * keyable. `sys_account.issuer` (bounded at 2048) is the live illustration that
40+ * the two rules are independent — it passes the gate and is out of this
41+ * describe's scope because its index is unique.
42+ *
43+ * The gate deliberately does not fold this in; its header says so.
9444 */
9545
9646const TEXT_FAMILY = new Set ( [ 'text' , 'textarea' , 'html' , 'markdown' ] ) ;
9747
98- /**
99- * Keyed text-family columns with NO defensible bound. Every entry must name
100- * why. Entries that stop matching a real keyed unbounded column fail the
101- * fourth test, so the list cannot rot.
102- *
103- * ⚠️ EMPTY since #11701 — and empty here is a RESULT, not a default. The list
104- * held exactly one entry, `sys_verification.value`, allowlisted because
105- * better-auth's oauth-provider writes OIDC authorization-code payloads there as
106- * a JSON blob and no bound provably admits all of them. That entry was written
107- * to explain why the column could not be BOUNDED, and the maintainer's
108- * 2026-08-25 ruling did not bound it — it removed the column's declared INDEX,
109- * on measured liveness. An unindexed column is not a keyed column, so the entry
110- * stopped describing anything real and moved with the change rather than being
111- * left to rot. (The fourth test enforces exactly that: it is what would have
112- * gone red had the entry been left behind.)
113- *
114- * ⚠️ Before adding an entry: an unboundable column may only be keyed by a
115- * UNIQUE index, which #11627 carries on a hash shadow. A NON-UNIQUE index over
116- * an unboundable column is not "debt" — it is unfixable, and the #11701
117- * `describe` below rejects it.
118- */
119- const UNBOUNDABLE : ReadonlySet < string > = new Set < string > ( [ ] ) ;
120-
12148/**
12249 * MySQL's utf8mb4 key-part ceiling, in CHARACTERS: 768 × 4 = 3072 bytes, the
12350 * whole key-part budget. A declared bound at or under this makes `driver-sql`
@@ -143,106 +70,6 @@ const platformObjects: AnyObject[] = Object.values(PlatformObjects)
14370 ! ! v . fields ,
14471 ) ;
14572
146- function keyedTextColumns ( o : AnyObject ) : Array < { column : string ; maxLength : unknown } > {
147- const keyed = new Set < string > ( ) ;
148- for ( const ix of o . indexes ?? [ ] ) for ( const f of ix . fields ?? [ ] ) keyed . add ( f ) ;
149- return Object . entries ( o . fields )
150- . filter ( ( [ name , def ] ) => keyed . has ( name ) && TEXT_FAMILY . has ( def ?. type ?? '' ) )
151- . map ( ( [ column , def ] ) => ( { column : `${ o . name } .${ column } ` , maxLength : def . maxLength } ) ) ;
152- }
153-
154- /**
155- * The rule the third test enforces, as a pure function of (objects, allowlist).
156- *
157- * Extracted rather than inlined because #11701 emptied `UNBOUNDABLE`: with the
158- * allowlist empty, the `allowlist.has(column)` branch is never taken against the
159- * real objects, so it would sit unexecuted and free to rot until the next agent
160- * needed it. The synthetic control below drives both of its outcomes.
161- */
162- function unboundedKeyedColumns ( objects : AnyObject [ ] , allowlist : ReadonlySet < string > ) : string [ ] {
163- const offenders : string [ ] = [ ] ;
164- for ( const o of objects ) {
165- for ( const { column, maxLength } of keyedTextColumns ( o ) ) {
166- if ( allowlist . has ( column ) ) continue ;
167- const bounded = typeof maxLength === 'number' && Number . isInteger ( maxLength ) && maxLength > 0 ;
168- if ( ! bounded ) offenders . push ( `${ column } (maxLength: ${ String ( maxLength ) } )` ) ;
169- }
170- }
171- return offenders ;
172- }
173-
174- describe ( 'platform keyed text-family columns declare their bound (#11374)' , ( ) => {
175- it ( 'enumerates a real surface — the probe itself is not vacuous' , ( ) => {
176- // Positive control: if the export shape or field/index spelling changes so
177- // this file stops seeing columns, fail loudly instead of passing empty.
178- const all = platformObjects . flatMap ( keyedTextColumns ) ;
179- expect ( platformObjects . length ) . toBeGreaterThanOrEqual ( 40 ) ;
180- expect ( all . length ) . toBeGreaterThanOrEqual ( 70 ) ;
181- expect ( all . map ( ( c ) => c . column ) ) . toContain ( 'sys_session.token' ) ;
182- } ) ;
183-
184- it ( 'reaches beyond identity/ — the scoping that let a keyed column escape' , ( ) => {
185- // The specific regression control for this file's own history: while it
186- // lived in `identity/` it enumerated only that directory, and
187- // `sys_import_job.created_by` (audit/) went unbounded through route A's
188- // first pass. These two names are in DIFFERENT source directories, so a
189- // future re-narrowing of the import fails here by name rather than by
190- // quietly enumerating less.
191- const columns = platformObjects . flatMap ( keyedTextColumns ) . map ( ( c ) => c . column ) ;
192- expect ( columns ) . toContain ( 'sys_import_job.created_by' ) ; // audit/
193- expect ( columns ) . toContain ( 'sys_metadata.name' ) ; // metadata/
194- expect ( columns ) . toContain ( 'sys_setting.key' ) ; // system/
195- } ) ;
196-
197- it ( 'every keyed text-family column declares a positive integer maxLength, or is allowlisted by name' , ( ) => {
198- const offenders = unboundedKeyedColumns ( platformObjects , UNBOUNDABLE ) ;
199- expect (
200- offenders ,
201- `keyed text-family column(s) without a declared maxLength — on MySQL their ` +
202- `declared index cannot be created and the object lands registered-but-broken. ` +
203- `Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` +
204- offenders . join ( ', ' ) ,
205- ) . toEqual ( [ ] ) ;
206- } ) ;
207-
208- it ( 'the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns' , ( ) => {
209- const real = new Map (
210- platformObjects . flatMap ( keyedTextColumns ) . map ( ( c ) => [ c . column , c . maxLength ] ) ,
211- ) ;
212- for ( const entry of UNBOUNDABLE ) {
213- expect ( real . has ( entry ) , `allowlist entry ${ entry } is not a keyed text column any more — remove it` ) . toBe ( true ) ;
214- expect (
215- real . get ( entry ) ,
216- `allowlist entry ${ entry } now declares a bound — remove it from UNBOUNDABLE` ,
217- ) . toBeUndefined ( ) ;
218- }
219- } ) ;
220-
221- /**
222- * ⚠️ The control that keeps the test above honest now that #11701 emptied the
223- * allowlist. An empty `for` loop passes, so with a real-objects-only check the
224- * excusing branch of the rule would be dead code that nobody notices rotting.
225- * This drives BOTH outcomes on a synthetic object, so the mechanism a future
226- * unboundable column will rely on is proven to work while the list is empty.
227- */
228- it ( 'the allowlist mechanism still excuses and still accuses — driven on a synthetic object' , ( ) => {
229- const synthetic : AnyObject [ ] = [
230- {
231- name : 'sys_probe' ,
232- fields : { blob : { type : 'text' } } ,
233- indexes : [ { fields : [ 'blob' ] , unique : true } ] ,
234- } ,
235- ] ;
236- // Keyed + unbounded, excused by nothing → an offender, named with its value.
237- expect ( unboundedKeyedColumns ( synthetic , new Set < string > ( ) ) ) . toEqual ( [
238- 'sys_probe.blob (maxLength: undefined)' ,
239- ] ) ;
240- // …and named in the allowlist → excused. The branch the real objects no
241- // longer reach.
242- expect ( unboundedKeyedColumns ( synthetic , new Set ( [ 'sys_probe.blob' ] ) ) ) . toEqual ( [ ] ) ;
243- } ) ;
244- } ) ;
245-
24673/**
24774 * #11701 — a NON-UNIQUE index over a text column MySQL cannot key.
24875 *
0 commit comments