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
26 changes: 26 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,6 +411,32 @@ jobs:
node scripts/check-undeclared-dep-imports.mjs --self-test
node scripts/check-undeclared-dep-imports.mjs

# A text-family column a DECLARED INDEX keys on must declare a `maxLength`
# (#12147, route A of #11374). Without one `driver-sql` emits it TEXT, MySQL
# refuses `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, and the
# object lands REGISTERED-BUT-BROKEN with its declared index silently absent
# (measured live on MySQL 8.0.46, #12058: 12 of 44 platform objects, sys_session
# among them). Enforcement used to be per-package pins, and each one was widened
# by a column that had escaped the previous scope -- objects keep moving across
# package boundaries under ADR-0029 K2, so a boundary-scoped pin re-opens the
# hole every time one moves. A central importing pin is NOT available: measured
# on PR #12143, it would invert the dependency graph. So this is a class-level
# source scan over every `*.object.ts`.
# Node builtins plus the shared comment mask only -- no node_modules, so a
# reviewer can run it in place. Its `--self-test` runs FIRST, and that leg is
# the load-bearing one: the production run over a fixed tree is green by
# construction, so it cannot tell a working matcher from a dead one. The other
# half is the FLOORS -- a sweep that finds nothing because it swept nothing
# reports exactly what a clean tree reports, so an empty population is `exit 2`
# rather than a pass. Unclassifiable shapes refuse for the same reason.
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
# Scans 113 *.object.ts files, no spawns; ~0.3s.
- name: Keyed text-family columns declare their bound (#12147)
run: |
node scripts/check-keyed-text-bounds.mjs --self-test
node scripts/check-keyed-text-bounds.mjs

# The bash-3.2 floor, over every shell file the repo ships (#12221).
# `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+,
# for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,7 @@
"check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs",
"check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs",
"check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs",
"check:keyed-text-bounds": "node scripts/check-keyed-text-bounds.mjs --self-test && node scripts/check-keyed-text-bounds.mjs",
"check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs",
"check:published-readme-exports": "node scripts/check-published-readme-exports.mjs --self-test && node scripts/check-published-readme-exports.mjs",
"check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs",
Expand Down
247 changes: 37 additions & 210 deletions packages/platform-objects/src/platform-keyed-text-bounds.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,120 +4,47 @@ import { describe, it, expect } from 'vitest';
import * as PlatformObjects from './index';

/**
* #11374 — every text-family column a declared index keys on must declare a
* `maxLength`, because a bound is what lets the column be a key at all.
*
* ## Why this pin exists
*
* `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when
* the field declares a bound the dialect can key on, and leaves it `TEXT`
* otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length
* (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means:
* `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the
* object lands registered-but-broken with its declared uniqueness silently
* absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed
* schema-sync this way — sys_session and sys_account among them, so a MySQL
* stack could not sign anyone in.
*
* The driver deliberately does NOT substitute a prefix index: measured on the
* same server, a prefix-UNIQUE index is stricter-and-different — it refused a
* second, genuinely distinct token that shared its first 191 characters
* (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound
* has to live HERE, in the field declaration (maintainer ruling on #11374,
* 2026-08-24: route A).
*
* ## Why this file enumerates the WHOLE package, not just `identity/`
*
* It used to be `identity/identity-keyed-text-bounds.test.ts`, importing
* `./index` from `identity/`. That scoping is precisely how
* `sys_import_job.created_by` — a keyed, unbounded text column in `audit/` —
* survived route A's first pass: the pin could not see it, so nothing failed by
* name and the column was left for a follow-up card to find by hand. A pin that
* polices one directory does not police the defect class; it polices a
* directory. The enumeration now walks every object the package exports, and
* the vacuity control below asserts a column from OUTSIDE `identity/` is in
* the enumerated set, so the same narrowing cannot silently come back.
*
* ## What a red on this file means
*
* A new keyed text-family field arrived without a `maxLength`. Do not silence
* the assertion — derive a bound from the value's producer (upstream
* better-auth schema/constraints, IdP norms, or the in-repo producer) and
* declare it. If the value source genuinely cannot be bounded, extend
* `UNBOUNDABLE` WITH a comment naming why — but read the #11701 block below
* first: an unboundable column may only be keyed by a UNIQUE index, because a
* UNIQUE index is the only kind #11627's hash shadow can carry.
*
* A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling —
* e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 —
* `sys_oauth_resource.identifier` is no longer among them, see #12313): the
* column then stays TEXT and its index still cannot exist on MySQL directly.
* That debt was #11627's, and #11627 discharged it for the UNIQUE half — such
* an index is now carried on a hash-shadow column. The first `describe` below
* still polices only "keyed text declares its bound".
*
* ## #11701 — the NON-UNIQUE half, which a hash shadow cannot serve
*
* The second `describe` polices the case #11627 deliberately left refused. A
* UNIQUE constraint is an equality-only predicate, so hashing the value
* preserves it exactly; a NON-UNIQUE index exists for an ACCESS PATH, and an
* index over a digest accelerates no `WHERE col = ?` the planner can reach
* without rewriting the read side. So for a non-unique index there is no
* shadow to fall back on: the column must be KEYABLE — bounded, and bounded at
* or under 768 — or the index cannot exist on MySQL at all and the object's
* whole schema-sync is refused.
*
* That left exactly two platform members, and the maintainer ruled them
* separately on 2026-08-25 because they are different problems:
*
* • `sys_verification.value` — unboundable AND unread. The declared index was
* REMOVED, on measured liveness (better-auth keys verification lookups on
* `identifier`; no in-repo query filters by `value`). Removing it is what
* emptied `UNBOUNDABLE` below.
* • `sys_oauth_client_resource.resource_id` — a LIVE access path (the FK side
* of `sys_oauth_resource.identifier`), so its bound was narrowed
* 1024 → 768 instead. See the field's own comment for the evidence that
* nothing legitimate lives in the discarded band.
*
* ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked
* 768 as the smallest narrowing that made the index expressible and left
* the number unsourced on purpose; #12313 sourced the REFERENT
* (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's
* own varchar(255) emission) and this column follows it, as a referencing
* column takes the referenced column's bound. 255 ≤ 768, so the #11701
* rule below is still satisfied — it is the same disposition at a sourced
* number, not a different one.
*
* The pin below is the executable form of "the class is closed": it does not
* name those two, it enumerates the whole package, so a THIRD member arriving
* later fails here rather than being found on a live MySQL months on.
* #11701 — a NON-UNIQUE declared index over a text column MySQL cannot key.
*
* ## What used to be here, and where it went (#12147)
*
* This file also carried route A's own rule — "every text-family column a
* declared index keys on declares a `maxLength`" (#11374) — enumerated over
* this package's exports, with a vacuity control, an `UNBOUNDABLE` allowlist
* and a synthetic control driving that allowlist's two branches. All of it is
* now `scripts/check-keyed-text-bounds.mjs`, which walks EVERY `*.object.ts` in
* the repository rather than one package's export surface.
*
* That is not a like-for-like move, and the difference is the reason for it.
* This pin enumerated `Object.values(PlatformObjects)`, so its population was
* whatever the barrel re-exports — 95 keyed text columns, measured. The gate's
* population over the same objects is 97: `sys_metadata_commit.package_id` and
* `sys_metadata_commit.parent_commit_id` were invisible here, because
* `metadata/index.ts` is a HAND-WRITTEN back-compat re-export naming four
* objects and `sys_metadata_commit` was never added to it. Both columns are
* bounded today, so nothing was broken — but nothing in the tree was watching
* them either, which is the same escape-by-boundary this pin was itself widened
* to close once before (`identity/` → the package, after
* `sys_import_job.created_by` slipped through).
*
* ## Why THIS half stays
*
* It is a different rule with a different disposition, not a narrower copy of
* the one that moved. Route A asks "is there a bound?"; this asks "is the
* declared bound small enough to be a key?" — and answers it only for
* NON-UNIQUE indexes, because a UNIQUE index over an unkeyable column is
* EXPRESSIBLE after #11627 (it moves onto a SHA-256 hash-shadow column) while a
* non-unique one is not: hashing destroys the ordering and prefix structure an
* access path is for, so there is no fallback and the column itself must be
* keyable. `sys_account.issuer` (bounded at 2048) is the live illustration that
* the two rules are independent — it passes the gate and is out of this
* describe's scope because its index is unique.
*
* The gate deliberately does not fold this in; its header says so.
*/

const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']);

/**
* Keyed text-family columns with NO defensible bound. Every entry must name
* why. Entries that stop matching a real keyed unbounded column fail the
* fourth test, so the list cannot rot.
*
* ⚠️ EMPTY since #11701 — and empty here is a RESULT, not a default. The list
* held exactly one entry, `sys_verification.value`, allowlisted because
* better-auth's oauth-provider writes OIDC authorization-code payloads there as
* a JSON blob and no bound provably admits all of them. That entry was written
* to explain why the column could not be BOUNDED, and the maintainer's
* 2026-08-25 ruling did not bound it — it removed the column's declared INDEX,
* on measured liveness. An unindexed column is not a keyed column, so the entry
* stopped describing anything real and moved with the change rather than being
* left to rot. (The fourth test enforces exactly that: it is what would have
* gone red had the entry been left behind.)
*
* ⚠️ Before adding an entry: an unboundable column may only be keyed by a
* UNIQUE index, which #11627 carries on a hash shadow. A NON-UNIQUE index over
* an unboundable column is not "debt" — it is unfixable, and the #11701
* `describe` below rejects it.
*/
const UNBOUNDABLE: ReadonlySet<string> = new Set<string>([]);

/**
* MySQL's utf8mb4 key-part ceiling, in CHARACTERS: 768 × 4 = 3072 bytes, the
* whole key-part budget. A declared bound at or under this makes `driver-sql`
Expand All@@ -143,106 +70,6 @@ const platformObjects: AnyObject[] = Object.values(PlatformObjects)
!!v.fields,
);

function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> {
const keyed = new Set<string>();
for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f);
return Object.entries(o.fields)
.filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? ''))
.map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength }));
}

/**
* The rule the third test enforces, as a pure function of (objects, allowlist).
*
* Extracted rather than inlined because #11701 emptied `UNBOUNDABLE`: with the
* allowlist empty, the `allowlist.has(column)` branch is never taken against the
* real objects, so it would sit unexecuted and free to rot until the next agent
* needed it. The synthetic control below drives both of its outcomes.
*/
function unboundedKeyedColumns(objects: AnyObject[], allowlist: ReadonlySet<string>): string[] {
const offenders: string[] = [];
for (const o of objects) {
for (const { column, maxLength } of keyedTextColumns(o)) {
if (allowlist.has(column)) continue;
const bounded = typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0;
if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`);
}
}
return offenders;
}

describe('platform keyed text-family columns declare their bound (#11374)', () => {
it('enumerates a real surface — the probe itself is not vacuous', () => {
// Positive control: if the export shape or field/index spelling changes so
// this file stops seeing columns, fail loudly instead of passing empty.
const all = platformObjects.flatMap(keyedTextColumns);
expect(platformObjects.length).toBeGreaterThanOrEqual(40);
expect(all.length).toBeGreaterThanOrEqual(70);
expect(all.map((c) => c.column)).toContain('sys_session.token');
});

it('reaches beyond identity/ — the scoping that let a keyed column escape', () => {
// The specific regression control for this file's own history: while it
// lived in `identity/` it enumerated only that directory, and
// `sys_import_job.created_by` (audit/) went unbounded through route A's
// first pass. These two names are in DIFFERENT source directories, so a
// future re-narrowing of the import fails here by name rather than by
// quietly enumerating less.
const columns = platformObjects.flatMap(keyedTextColumns).map((c) => c.column);
expect(columns).toContain('sys_import_job.created_by'); // audit/
expect(columns).toContain('sys_metadata.name'); // metadata/
expect(columns).toContain('sys_setting.key'); // system/
});

it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => {
const offenders = unboundedKeyedColumns(platformObjects, UNBOUNDABLE);
expect(
offenders,
`keyed text-family column(s) without a declared maxLength — on MySQL their ` +
`declared index cannot be created and the object lands registered-but-broken. ` +
`Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` +
offenders.join(', '),
).toEqual([]);
});

it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => {
const real = new Map(
platformObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]),
);
for (const entry of UNBOUNDABLE) {
expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true);
expect(
real.get(entry),
`allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`,
).toBeUndefined();
}
});

/**
* ⚠️ The control that keeps the test above honest now that #11701 emptied the
* allowlist. An empty `for` loop passes, so with a real-objects-only check the
* excusing branch of the rule would be dead code that nobody notices rotting.
* This drives BOTH outcomes on a synthetic object, so the mechanism a future
* unboundable column will rely on is proven to work while the list is empty.
*/
it('the allowlist mechanism still excuses and still accuses — driven on a synthetic object', () => {
const synthetic: AnyObject[] = [
{
name: 'sys_probe',
fields: { blob: { type: 'text' } },
indexes: [{ fields: ['blob'], unique: true }],
},
];
// Keyed + unbounded, excused by nothing → an offender, named with its value.
expect(unboundedKeyedColumns(synthetic, new Set<string>())).toEqual([
'sys_probe.blob (maxLength: undefined)',
]);
// …and named in the allowlist → excused. The branch the real objects no
// longer reach.
expect(unboundedKeyedColumns(synthetic, new Set(['sys_probe.blob']))).toEqual([]);
});
});

/**
* #11701 — a NON-UNIQUE index over a text column MySQL cannot key.
*
Expand Down
Loading
Loading