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
49 changes: 49 additions & 0 deletions .changeset/non-unique-keyed-text-remainders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@objectstack/platform-objects': minor
---

Make the last two non-unique keyed text indexes expressible on MySQL — remove one,
narrow one

`driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the
declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part
ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key
(`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands
registered with its declared index absent. #11374 declared sourced bounds for thirteen
such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow
column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects.

The two that remained are **non-unique**, and a hash shadow structurally cannot serve
them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly,
but 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.
They are ruled separately (maintainer, 2026-08-25) because they are different problems:

- **`sys_verification.value` — the declared index is removed.** The column is
genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code
payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1
keys every verification lookup on `identifier`, `id` or `expiresAt`
(`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no
in-repo query filters `sys_verification` by `value`. An index that silently does not
exist on one dialect is the worst of both worlds; removing it makes the metadata match
reality.
- **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.**
This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read
as a predicate by upstream's client-registration collision path), so it keeps its
index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part
holds, and the smallest narrowing that works.

This is an enforcement change on published objects — hence the minor grade. On MySQL and
SQL Server a `resource_id` longer than 768 characters is now refused rather than stored,
and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the
next schema sync (on MySQL it never existed). Neither narrows what the producing
contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream
better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL
(`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose
identifier exceeded 768 characters could never have been registered upstream at all.

The pin that enumerated the package for unbounded keyed text columns now also rejects a
non-unique index over any text column MySQL cannot key, so a third member of the class
fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which
existed to excuse `sys_verification.value` — is empty as a result, and a synthetic
control keeps the excusing branch exercised rather than letting it rot.
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,35 @@ export const SysOauthClientResource = ObjectSchema.create({
resource_id: Field.text({
label: 'Resource ID',
required: true,
maxLength: 1024,
// [#11701] Narrowed 1024 → 768 so the declared `[resource_id]` index can
// exist at all. At 1024 the column stays TEXT on MySQL and the index is
// refused (`ER_BLOB_KEY_WITHOUT_LENGTH`), taking the whole object's
// schema-sync down with it; 768 characters is the widest utf8mb4 value a
// MySQL key part can hold (768 × 4 = 3072 bytes, exactly the ceiling).
//
// ⚠️ This is the one bound in the family that does NOT simply take its
// referenced column's width: `sys_oauth_resource.identifier` declares
// 1024. Narrowing below the referent is safe here because the
// (768, 1024] band holds nothing the PRODUCING contract can emit. The
// value is an RFC 8707 resource-indicator URI, and upstream better-auth
// 1.7.1 — the sole writer of this table (`managedBy: 'better-auth'`) —
// stores that same identifier in `oauthResource.identifier` as
// **varchar(255)** on MySQL (`better-auth/dist/db/get-migration.mjs`,
// `getType`: a unique string column → varchar(255)) and this referring
// column as varchar(36) (its `field.references` branch). A resource
// whose identifier exceeded 768 characters could never have been
// registered upstream in the first place.
//
// 768 rather than upstream's 255 on purpose: it is the SMALLEST
// narrowing that makes the index expressible, so it rejects the least of
// the referent's declared domain. Guessing a tighter number to make a
// key fit is what `sys_account.issuer` refuses to do.
//
// ⛔ Unlike `sys_verification.value`, this index is NOT removable: this
// is the FK side of `sys_oauth_resource.identifier` and upstream reads it
// as a predicate (`findOne({ clientId, resourceId })` on the client
// registration collision path), so it is a live access path.
maxLength: 768,
description: 'Foreign key to sys_oauth_resource.identifier',
}),

Expand Down
41 changes: 34 additions & 7 deletions packages/platform-objects/src/identity/sys-verification.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export const SysVerification = ObjectSchema.create({
value: Field.text({
label: 'Verification Token',
required: true,
// [#11374/#11701] Deliberately UNBOUNDED: better-auth's oauth-provider
// writes OIDC authorization-code payloads here as a JSON blob, so no
// bound provably admits every value it may write. That is only
// survivable because the column carries no index — see the `indexes`
// note below, which is what makes an unbounded TEXT column safe on
// MySQL.
description: 'Token or code for verification',
}),

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

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