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
39 changes: 39 additions & 0 deletions .changeset/import-job-created-by-bound.md
Original file line numberDiff line numberDiff line change
@@ -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.
30 changes: 29 additions & 1 deletion packages/platform-objects/src/audit/sys-import-job.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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
Expand All@@ -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
Expand All@@ -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<string> = new Set([
// better-auth's oauth-provider stores OIDC authorization-code payloads in
Expand All@@ -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) =>
Expand All@@ -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 =
Expand All@@ -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);
Expand Down
Loading