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
42 changes: 42 additions & 0 deletions .changeset/adr-0104-d3w2-pr5a-write-cutover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
"@objectstack/service-storage": minor
---

feat(spec)!: media fields declare accept/maxSize, and the stored form is a file reference — ADR-0104 D3 wave 2 (PR-5a)

**`accept` and `maxSize` are now declared on `FieldSchema`, and enforced on the
server.** Both were already read by the upload widgets — `field.accept`,
`field.maxSize` — while the spec did not declare them, so an author who wrote
them had the keys silently stripped at parse and the constraint simply never
existed. That is exactly the ADR-0104 failure class (a declaration accepted in
source, dropped from the contract, with no feedback).

Now that the platform owns the file, `sys_file` carries the authoritative MIME
type and byte size, so a record write is re-checked against the declaration
where it actually binds rather than only in the browser — a client-side check is
a convenience, not a control, since any caller talking to the API directly
bypasses it. Violations raise `FileConstraintError` and fail the write. An entry
is only judged against metadata the file actually reports: a file with no
recorded MIME type cannot fail an `accept` test, and one with no recorded size
cannot fail `maxSize` — "we don't know" must not become "not permitted".

**The stored form of a media field narrows to an opaque `sys_file` id.**
`valueSchemaFor(field, 'stored')` now yields an id for `file`/`image`/`avatar`/
`video`/`audio`; the inline `{url, name, size, …}` blob becomes the `'expanded'`
read form, which also still admits an unresolved id (storage service absent,
file not committed) exactly as an unexpanded lookup id stays valid.

Two legacy forms therefore stop conforming, both deliberately:

- the **inline blob**, which is no longer stored but derived;
- an **external URL**, which was never a managed file — ADR-0104 R7 retires it
toward an explicit `url` field, and under AI authoring that is the point: it
stops "managed file" and "external link" being the same declaration.

**Not a breaking change today.** Value-shape checking is warn-first
(ADR-0104 R1/R2): a not-yet-backfilled row still writes and the author gets a
warning naming the field. Hard rejection arrives only when a deployment opts
into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do after running the
backfill and confirming reconciliation. The `!` marks the contract change for
the v17 window, not a runtime break on upgrade.
7 changes: 5 additions & 2 deletions content/docs/references/data/field-value.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ this contract has (ADR-0104 performance budget).
## TypeScript Usage

```typescript
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';

// Validate data
const result = AddressValue.parse(data);
Expand Down Expand Up @@ -118,6 +118,9 @@ Type: `string`
---


---


---

## FileValue
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ const result = Address.parse(data);
| **scale** | `number` | optional | Decimal places |
| **min** | `number` | optional | Minimum value |
| **max** | `number` | optional | Maximum value |
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. |
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional | What happens if referenced record is deleted |
Expand Down
14 changes: 13 additions & 1 deletion packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,25 @@ describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () =>
const data = {
account: 'acc_0001',
geo: { lat: 37.77, lng: -122.42 },
doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 },
// The STORED form of a media field is an opaque sys_file id (ADR-0104 D3
// wave 2); the inline blob is now the expanded READ form.
doc: 'file_01HXYZ',
dims: [0.1, 0.2],
};
expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow();
withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow());
});

it('warn-first keeps a legacy inline blob writable until strict is opted into', () => {
// The migration path that makes narrowing the stored form safe to ship: a
// not-yet-backfilled row still saves and the author gets a value-shape
// warning naming the field, rather than the write failing on data that was
// valid when it was written.
const legacy = { doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } };
expect(() => validateRecord(schema, { ...legacy }, 'update')).not.toThrow();
withStrict(() => expect(() => validateRecord(schema, { ...legacy }, 'update')).toThrow());
});

it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => {
expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow();
expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow();
Expand Down
15 changes: 10 additions & 5 deletions packages/qa/dogfood/test/field-zoo.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,20 @@ export const MATRIX: FieldCase[] = [
{ field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } },
// object-valued types that must store/parse as JSON, not stringify to TEXT
{ field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } },
{ field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } },
{ field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } },
// media — the STORED form is an opaque sys_file id (ADR-0104 D3 wave 2); the
// inline `{url, …}` blob is the expanded READ form, derived by the resolver
// rather than written. These ids match no sys_file row here, which is the
// point: the round-trip must return the stored id verbatim when there is
// nothing to expand it into.
{ field: 'f_video', type: 'video', check: { kind: 'equal', write: 'file_zoo_video' } },
{ field: 'f_audio', type: 'audio', check: { kind: 'equal', write: 'file_zoo_audio' } },
{ field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } },
{ field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } },
{ field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } },
{ field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } },
{ field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } },
{ field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } },
{ field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } },
{ field: 'f_image', type: 'image', check: { kind: 'equal', write: 'file_zoo_image' } },
{ field: 'f_file', type: 'file', check: { kind: 'equal', write: 'file_zoo_doc' } },
{ field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: 'file_zoo_avatar' } },
// relational — store a reference id as a string and read it back verbatim.
// FK enforcement is off in this harness, so this asserts value fidelity
// (id string → id string), not referential integrity / $expand (covered
Expand Down
104 changes: 104 additions & 0 deletions packages/services/service-storage/src/file-reference-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
import {
installFileReferenceHooks,
FileReferenceCopyError,
FileConstraintError,
type FileReferenceEngine,
} from './file-reference-lifecycle.js';

Expand Down Expand Up @@ -494,6 +495,109 @@ describe('File Reference Ownership (ADR-0104 D3 wave 2)', () => {
});
});

// ── Declared media constraints (ADR-0104 D3 wave 2) ──────────────
describe('accept / maxSize enforcement', () => {
const constrained = (over: Record<string, any> = {}) => ({
sys_file: REGISTRY.sys_file,
product: {
name: 'product',
fields: {
id: { type: 'text' },
image: { type: 'image', accept: ['image/*'], maxSize: 1000, ...over },
gallery: { type: 'image', multiple: true },
},
},
});

it('rejects a file larger than the declared maxSize', async () => {
const engine = fakeEngine({
files: [file({ size: 5000 })],
registry: constrained(),
});
install(engine);

await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
FileConstraintError,
);
// The write failed, so nothing was claimed.
expect(engine.tables.sys_file[0].ref_id).toBeUndefined();
});

it('rejects a file whose MIME type is outside the declared accept list', async () => {
const engine = fakeEngine({
files: [file({ mime_type: 'application/pdf', name: 'a.pdf', size: 10 })],
registry: constrained(),
});
install(engine);

await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
/not permitted by the accept list/,
);
});

it('accepts a file that satisfies both declarations', async () => {
const engine = fakeEngine({ files: [file({ size: 10 })], registry: constrained() });
install(engine);

await driveInsert(engine, 'product', { image: 'file_a' }, 'p1');

expect(engine.tables.sys_file[0].ref_id).toBe('p1');
});

it.each([
[['image/png'], 'image/png', 'a.png', true],
[['image/*'], 'image/jpeg', 'a.jpg', true],
[['.pdf'], 'application/pdf', 'report.pdf', true],
[['.pdf'], 'application/pdf', 'report.txt', false],
[['image/png'], 'image/jpeg', 'a.jpg', false],
[['*/*'], 'anything/at-all', 'x', true],
])('accept %j vs %s/%s → %s', async (accept, mime, name, allowed) => {
const engine = fakeEngine({
files: [file({ mime_type: mime, name, size: 10 })],
registry: constrained({ accept, maxSize: undefined }),
});
install(engine);

const write = driveInsert(engine, 'product', { image: 'file_a' }, 'p1');
if (allowed) {
await write;
expect(engine.tables.sys_file[0].ref_id).toBe('p1');
} else {
await expect(write).rejects.toThrow(FileConstraintError);
}
});

/**
* Missing metadata is not evidence of a violation — a sys_file row with no
* recorded size or MIME type must not be rejected by a constraint it
* cannot be tested against.
*/
it('does not reject a file whose size or MIME type is unrecorded', async () => {
const engine = fakeEngine({
files: [{ id: 'file_a', key: 'user/file_a', name: 'file_a', status: 'committed' }],
registry: constrained(),
});
install(engine);

await driveInsert(engine, 'product', { image: 'file_a' }, 'p1');

expect(engine.tables.sys_file[0].ref_id).toBe('p1');
});

it('leaves a field with no declared constraints alone', async () => {
const engine = fakeEngine({
files: [file({ mime_type: 'application/pdf', size: 10_000_000 })],
registry: constrained(),
});
install(engine);

// `gallery` declares neither accept nor maxSize.
await driveInsert(engine, 'product', { gallery: ['file_a'] }, 'p1');

expect(engine.tables.sys_file[0].ref_field).toBe('gallery');
});
});

// ── Unknown ids ──────────────────────────────────────────────────
it('leaves an id that matches no sys_file row untouched', async () => {
const engine = fakeEngine({ files: [file()] });
Expand Down
106 changes: 103 additions & 3 deletions packages/services/service-storage/src/file-reference-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export class FileReferenceCopyError extends Error {
}
}

/**
* Raised when a referenced file violates its field's declared `accept` /
* `maxSize`. Fails the write: a stored value that breaks its own field's
* declaration is the "declared but not enforced" state ADR-0104 removes.
*/
export class FileConstraintError extends Error {
readonly code = 'ERR_FILE_CONSTRAINT';
constructor(message: string) {
super(message);
}
}

function asIdList(id: unknown): Array<string | number> | null {
if (typeof id === 'string' || typeof id === 'number') return [id];
if (id && typeof id === 'object' && Array.isArray((id as any).$in)) {
Expand All @@ -120,6 +132,83 @@ function fileFieldsOf(engine: FileReferenceEngine, objectName: string): string[]
.map(([name]) => name);
}

/** One field's definition, or undefined. */
function fieldDefOf(engine: FileReferenceEngine, objectName: string, field: string): any {
return (engine.getObject(objectName) as any)?.fields?.[field];
}

/**
* Does `mime` satisfy one `accept` entry? Accepts the same vocabulary the file
* picker does: an exact MIME type, a `type/*` wildcard, or a `.ext` suffix
* (matched against the file NAME, since a browser-supplied extension is what
* the author is describing).
*/
function matchesAcceptEntry(entry: string, mime: string, name: string): boolean {
const e = entry.trim().toLowerCase();
if (!e) return false;
if (e === '*' || e === '*/*') return true;
if (e.startsWith('.')) return name.toLowerCase().endsWith(e);
if (e.endsWith('/*')) return mime.startsWith(e.slice(0, -1));
return mime === e;
}

/**
* Enforce a media field's declared `accept` / `maxSize` against the file the
* record is about to reference.
*
* The upload widget checks both before uploading, but that check is a
* convenience rather than a control — any caller talking to the API directly
* bypasses it. Now that the platform owns the file, `sys_file` carries the
* authoritative MIME type and byte size, so the constraint can be re-checked
* where it actually binds. Throws {@link FileConstraintError}, failing the
* write, because a stored value violating its own field's declaration is
* exactly the "declared but not enforced" state ADR-0104 exists to remove.
*
* Only checks what the file actually reports: a `sys_file` row with no
* `mime_type` cannot fail an `accept` test, and one with no `size` cannot fail
* `maxSize`. Missing metadata is not evidence of a violation.
*/
function assertFileConstraints(
fieldDef: any,
field: string,
file: Record<string, unknown>,
): void {
const accept: unknown = fieldDef?.accept;
const maxSize: unknown = fieldDef?.maxSize;

if (typeof maxSize === 'number' && maxSize > 0 && typeof file.size === 'number') {
if (file.size > maxSize) {
throw new FileConstraintError(
`File exceeds the maximum size declared for '${field}' ` +
`(${file.size} bytes > ${maxSize} bytes)`,
);
}
}

if (Array.isArray(accept) && accept.length > 0) {
const mime = typeof file.mime_type === 'string' ? file.mime_type.toLowerCase() : '';
const name = typeof file.name === 'string' ? file.name : '';
const hasExtension = name.includes('.');

// An entry can only be judged against metadata the file actually reports:
// a MIME entry needs a recorded MIME type, an extension entry needs a name
// carrying an extension. Entries that cannot be evaluated are not failures
// — rejecting on them would turn "we don't know" into "not permitted".
const testable = accept.filter((e): e is string => {
if (typeof e !== 'string' || !e.trim()) return false;
return e.trim().startsWith('.') ? hasExtension : !!mime;
});
if (testable.length === 0) return;

if (!testable.some((e) => matchesAcceptEntry(e, mime, name))) {
throw new FileConstraintError(
`File type '${mime || name}' is not permitted by the accept list declared for ` +
`'${field}' (${accept.join(', ')})`,
);
}
}
}

/** The id tokens a field value carries (a `multiple: true` field holds an
* array). Inline blobs and URL-shaped strings yield nothing. */
function idTokensIn(value: unknown): string[] {
Expand Down Expand Up @@ -207,9 +296,15 @@ async function copyOwnedFile(

/**
* Rewrite, in place, any id in `data` that is already owned by a different
* slot so it points at a fresh copy instead. Runs in the BEFORE hooks, where
* mutating `input.data` is what the driver goes on to persist — so the record
* never transiently holds a reference it does not own.
* slot so it points at a fresh copy instead, and enforce each referenced
* file's declared `accept` / `maxSize` on the way past. Runs in the BEFORE
* hooks, where mutating `input.data` is what the driver goes on to persist —
* so the record never transiently holds a reference it does not own, and never
* persists one that violates its field's declaration.
*
* The constraint check rides here because this pass already loads every
* referenced `sys_file` row; enforcing it anywhere else would mean a second
* read of the same rows.
*
* `recordId` is null on insert: a new record can never be the current owner,
* so every already-owned id is copied.
Expand Down Expand Up @@ -238,6 +333,11 @@ async function applyCopyOnClaim(
// Unknown id: not a file this platform manages (external/legacy value).
// Left verbatim — the read resolver ignores it for the same reason.
if (!row) continue;

// The file is real and about to be referenced by this field, so its
// declared constraints bind now — before ownership, before any copy.
assertFileConstraints(fieldDefOf(engine, object, field), field, row);

// Unclaimed: the after-hook will claim it for this slot. Nothing to copy.
if (row.ref_id == null) continue;
// Already ours (an update rewriting the same value) — no-op.
Expand Down
6 changes: 5 additions & 1 deletion packages/services/service-storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export type { StorageRoutesOptions, FileReadVerdict } from './storage-routes.js'
export { SystemFile, SystemUploadSession } from './objects/index.js';
export { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard } from './attachment-lifecycle.js';
export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js';
export { installFileReferenceHooks, FileReferenceCopyError } from './file-reference-lifecycle.js';
export {
installFileReferenceHooks,
FileReferenceCopyError,
FileConstraintError,
} from './file-reference-lifecycle.js';
export type { FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js';
export {
verifyFileReferences,
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@
"FieldSchema (const)",
"FieldType (type)",
"FileLikeValueSchema (const)",
"FileReferenceIdValueSchema (const)",
"FileValueSchema (const)",
"Filter (type)",
"FilterCondition (type)",
Expand Down
Loading