diff --git a/.changeset/import-job-created-by-bound.md b/.changeset/import-job-created-by-bound.md new file mode 100644 index 0000000000..6db347e9e1 --- /dev/null +++ b/.changeset/import-job-created-by-bound.md @@ -0,0 +1,39 @@ +--- +'@objectstack/platform-objects': minor +--- + +Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared +index can exist on MySQL — route A's last column + +`driver-sql` (since #11430) honours a keyed text-family field's declared +`maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699 +declared bounds on thirteen keyed identity columns. `sys_import_job.created_by` +is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL +that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot +be a key without a prefix length) and the object landed registered-but-broken. +It was the only remaining such object outside the >768-character class that +#11627 tracks. + +The bound is **255**, derived by referenced-column transitivity rather than +chosen: the column holds a `sys_user.id` stamped by the rest-server import route +from `context.userId`, and `driver-sql` creates every table's primary key as +`table.string('id').primary()` — knex's `varchar(255)` — so no id this column +can receive exceeds 255. It agrees with what the column would get if declared +like its siblings (`Field.lookup('sys_user')` emits +`DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the +same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`, +`sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so +the bound clears the floor with 229 characters of headroom. + +This is behaviour-narrowing on a published object: on a strict MySQL server a +`created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows) +rather than stored, where previously the column was unbounded `TEXT`. No value +the producing contract can emit is affected, because the id it copies is itself +capped at 255 by its own column. + +The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to +`platform-keyed-text-bounds.test.ts` and now enumerates **every** platform +object the package exports, not just `identity/`. That directory scoping is +exactly how this column escaped the first pass — the pin could not see it — and +a new control asserts the enumeration reaches columns in `audit/`, `metadata/` +and `system/` so the narrowing cannot silently return. diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 7ae82cde8d..75930e4af2 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -92,7 +92,35 @@ export const SysImportJob = ObjectSchema.create({ // ── lifecycle timestamps ── started_at: Field.datetime({ label: 'Started At', required: false, group: 'State' }), completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }), - created_by: Field.text({ label: 'Created By', required: false, readonly: true, group: 'System' }), + // [#11374 route A] The value is `context.userId`, stamped by the rest-server + // import route (`String(context?.userId ?? context?.user?.id ?? '')` in + // `rest-server.ts`) — i.e. a `sys_user.id`. The bound is derived by + // referenced-column transitivity from three converging in-repo producers, + // never guessed: + // - the physical column the id itself lives in: driver-sql creates every + // table's primary key as `table.string('id').primary()`, which is knex's + // `varchar(255)`, so no id this column can ever receive exceeds 255; + // - what this column would be if it were declared like its siblings: every + // other actor column on a platform object is `Field.lookup('sys_user')`, + // which driver-sql emits at `DEFAULT_STRING_VARCHAR_CHARS` = 255; + // - the landed text declarations for the same value class: + // `sys_metadata_audit.actor`, `sys_metadata_commit.actor` and + // `sys_view_definition.owner` all declare `maxLength: 255`. + // The floor is cleared with room to spare: a minted platform id is 26 + // characters (measured on #11431, where honouring a bound below that made a + // column structurally unable to hold any id at all). + // 255 is also <= the 768-character utf8mb4 key ceiling, so the + // `(created_by, created_at)` index below is expressible on MySQL — which is + // the whole point: unbounded, this column was emitted TEXT and MySQL refused + // the index with `ER_BLOB_KEY_WITHOUT_LENGTH`, landing the object + // registered-but-broken. + created_by: Field.text({ + label: 'Created By', + required: false, + readonly: true, + maxLength: 255, + group: 'System', + }), created_at: Field.datetime({ label: 'Created At', required: true, diff --git a/packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts similarity index 72% rename from packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts rename to packages/platform-objects/src/platform-keyed-text-bounds.test.ts index 3809808f77..adf7248c73 100644 --- a/packages/platform-objects/src/identity/identity-keyed-text-bounds.test.ts +++ b/packages/platform-objects/src/platform-keyed-text-bounds.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import * as Identity from './index'; +import * as PlatformObjects from './index'; /** * #11374 — every text-family column a declared index keys on must declare a @@ -26,6 +26,18 @@ import * as Identity from './index'; * 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 @@ -47,7 +59,7 @@ 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 - * second test, so the list cannot rot. + * third test, so the list cannot rot. */ const UNBOUNDABLE: ReadonlySet = new Set([ // better-auth's oauth-provider stores OIDC authorization-code payloads in @@ -65,7 +77,7 @@ type AnyObject = { indexes?: Array<{ fields?: string[]; unique?: boolean }>; }; -const identityObjects: AnyObject[] = Object.values(Identity) +const platformObjects: AnyObject[] = Object.values(PlatformObjects) .map((v) => v as unknown as AnyObject) .filter( (v) => @@ -84,19 +96,32 @@ function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unkn .map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength })); } -describe('identity keyed text-family columns declare their bound (#11374)', () => { +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 = identityObjects.flatMap(keyedTextColumns); - expect(identityObjects.length).toBeGreaterThanOrEqual(20); - expect(all.length).toBeGreaterThanOrEqual(30); + 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: string[] = []; - for (const o of identityObjects) { + for (const o of platformObjects) { for (const { column, maxLength } of keyedTextColumns(o)) { if (UNBOUNDABLE.has(column)) continue; const bounded = @@ -115,7 +140,7 @@ describe('identity keyed text-family columns declare their bound (#11374)', () = it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => { const real = new Map( - identityObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]), + 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);