Skip to content

Commit fe67e34

Browse files
os-zhuangclaude
andauthored
feat(spec)!: media accept/maxSize declared + enforced, stored form is a reference — ADR-0104 D3 wave 2 (PR-5a) (#3555)
* feat(spec)!: media accept/maxSize declared + enforced, stored form is a 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 this ADR exists to remove: a declaration accepted in source, dropped from the contract, with no feedback anywhere. Now that the platform owns the file, sys_file carries the authoritative MIME type and byte size, so a record write is re-checked where the constraint 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. The check rides the pass that already loads each referenced sys_file row, so it costs no extra read, and an entry is only judged against metadata the file actually reports: no recorded MIME type cannot fail an accept test, no recorded size cannot fail maxSize. "We don't know" must not become "not permitted" — a test caught that distinction being got wrong. The stored form of a media field narrows to an opaque sys_file id. valueSchemaFor(field, 'stored') now yields an id for the whole media family; 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 stop conforming, both deliberately: the inline blob, which is no longer stored but derived; and an external URL, which was never a managed file — R7 retires those toward an explicit `url` field, and under AI authoring that IS the point, since it stops "managed file" and "external link" being the same declaration. Not a breaking change today. Value-shape checking is warn-first (R1/R2): a not-yet-backfilled row still writes and the author gets a warning naming the field. Hard rejection arrives only under OS_DATA_VALUE_SHAPE_STRICT_ENABLED, which a deployment opts into after running the backfill and confirming reconciliation. The `!` marks the contract change for the v17 window, not a runtime break on upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * chore(spec): register accept/maxSize in the field liveness ledger The spec-property liveness gate (ADR-0049, declared != enforced) flagged both new properties as UNCLASSIFIED — which is the gate doing exactly its job, since the whole reason these two are being added is that they were read by widgets while nothing in the contract declared or enforced them. Both are registered live, with the server-side enforcement site as evidence rather than the widget that merely offers them to the file picker: a client-side check is bypassed by any caller talking to the API directly, so it is not what makes the property live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57bab76 commit fe67e34

14 files changed

Lines changed: 386 additions & 26 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-storage": minor
4+
---
5+
6+
feat(spec)!: media fields declare accept/maxSize, and the stored form is a file reference — ADR-0104 D3 wave 2 (PR-5a)
7+
8+
**`accept` and `maxSize` are now declared on `FieldSchema`, and enforced on the
9+
server.** Both were already read by the upload widgets — `field.accept`,
10+
`field.maxSize` — while the spec did not declare them, so an author who wrote
11+
them had the keys silently stripped at parse and the constraint simply never
12+
existed. That is exactly the ADR-0104 failure class (a declaration accepted in
13+
source, dropped from the contract, with no feedback).
14+
15+
Now that the platform owns the file, `sys_file` carries the authoritative MIME
16+
type and byte size, so a record write is re-checked against the declaration
17+
where it actually binds rather than only in the browser — a client-side check is
18+
a convenience, not a control, since any caller talking to the API directly
19+
bypasses it. Violations raise `FileConstraintError` and fail the write. An entry
20+
is only judged against metadata the file actually reports: a file with no
21+
recorded MIME type cannot fail an `accept` test, and one with no recorded size
22+
cannot fail `maxSize` — "we don't know" must not become "not permitted".
23+
24+
**The stored form of a media field narrows to an opaque `sys_file` id.**
25+
`valueSchemaFor(field, 'stored')` now yields an id for `file`/`image`/`avatar`/
26+
`video`/`audio`; the inline `{url, name, size, …}` blob becomes the `'expanded'`
27+
read form, which also still admits an unresolved id (storage service absent,
28+
file not committed) exactly as an unexpanded lookup id stays valid.
29+
30+
Two legacy forms therefore stop conforming, both deliberately:
31+
32+
- the **inline blob**, which is no longer stored but derived;
33+
- an **external URL**, which was never a managed file — ADR-0104 R7 retires it
34+
toward an explicit `url` field, and under AI authoring that is the point: it
35+
stops "managed file" and "external link" being the same declaration.
36+
37+
**Not a breaking change today.** Value-shape checking is warn-first
38+
(ADR-0104 R1/R2): a not-yet-backfilled row still writes and the author gets a
39+
warning naming the field. Hard rejection arrives only when a deployment opts
40+
into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do after running the
41+
backfill and confirming reconciliation. The `!` marks the contract change for
42+
the v17 window, not a runtime break on upgrade.

content/docs/references/data/field-value.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ this contract has (ADR-0104 performance budget).
5858
## TypeScript Usage
5959

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

6464
// Validate data
6565
const result = AddressValue.parse(data);
@@ -118,6 +118,9 @@ Type: `string`
118118
---
119119

120120

121+
---
122+
123+
121124
---
122125

123126
## FileValue

content/docs/references/data/field.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const result = Address.parse(data);
113113
| **scale** | `number` | optional | Decimal places |
114114
| **min** | `number` | optional | Minimum value |
115115
| **max** | `number` | optional | Maximum value |
116+
| **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. |
117+
| **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. |
116118
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |
117119
| **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. |
118120
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional | What happens if referenced record is deleted |

packages/objectql/src/validation/record-validator.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,25 @@ describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () =>
372372
const data = {
373373
account: 'acc_0001',
374374
geo: { lat: 37.77, lng: -122.42 },
375-
doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 },
375+
// The STORED form of a media field is an opaque sys_file id (ADR-0104 D3
376+
// wave 2); the inline blob is now the expanded READ form.
377+
doc: 'file_01HXYZ',
376378
dims: [0.1, 0.2],
377379
};
378380
expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow();
379381
withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow());
380382
});
381383

384+
it('warn-first keeps a legacy inline blob writable until strict is opted into', () => {
385+
// The migration path that makes narrowing the stored form safe to ship: a
386+
// not-yet-backfilled row still saves and the author gets a value-shape
387+
// warning naming the field, rather than the write failing on data that was
388+
// valid when it was written.
389+
const legacy = { doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } };
390+
expect(() => validateRecord(schema, { ...legacy }, 'update')).not.toThrow();
391+
withStrict(() => expect(() => validateRecord(schema, { ...legacy }, 'update')).toThrow());
392+
});
393+
382394
it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => {
383395
expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow();
384396
expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow();

packages/qa/dogfood/test/field-zoo.matrix.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,20 @@ export const MATRIX: FieldCase[] = [
7373
{ field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } },
7474
// object-valued types that must store/parse as JSON, not stringify to TEXT
7575
{ field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } },
76-
{ field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } },
77-
{ field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } },
76+
// media — the STORED form is an opaque sys_file id (ADR-0104 D3 wave 2); the
77+
// inline `{url, …}` blob is the expanded READ form, derived by the resolver
78+
// rather than written. These ids match no sys_file row here, which is the
79+
// point: the round-trip must return the stored id verbatim when there is
80+
// nothing to expand it into.
81+
{ field: 'f_video', type: 'video', check: { kind: 'equal', write: 'file_zoo_video' } },
82+
{ field: 'f_audio', type: 'audio', check: { kind: 'equal', write: 'file_zoo_audio' } },
7883
{ field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } },
7984
{ field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } },
8085
{ field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } },
8186
{ field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } },
82-
{ field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } },
83-
{ field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } },
84-
{ field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } },
87+
{ field: 'f_image', type: 'image', check: { kind: 'equal', write: 'file_zoo_image' } },
88+
{ field: 'f_file', type: 'file', check: { kind: 'equal', write: 'file_zoo_doc' } },
89+
{ field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: 'file_zoo_avatar' } },
8590
// relational — store a reference id as a string and read it back verbatim.
8691
// FK enforcement is off in this harness, so this asserts value fidelity
8792
// (id string → id string), not referential integrity / $expand (covered

packages/services/service-storage/src/file-reference-lifecycle.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
44
import {
55
installFileReferenceHooks,
66
FileReferenceCopyError,
7+
FileConstraintError,
78
type FileReferenceEngine,
89
} from './file-reference-lifecycle.js';
910

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

498+
// ── Declared media constraints (ADR-0104 D3 wave 2) ──────────────
499+
describe('accept / maxSize enforcement', () => {
500+
const constrained = (over: Record<string, any> = {}) => ({
501+
sys_file: REGISTRY.sys_file,
502+
product: {
503+
name: 'product',
504+
fields: {
505+
id: { type: 'text' },
506+
image: { type: 'image', accept: ['image/*'], maxSize: 1000, ...over },
507+
gallery: { type: 'image', multiple: true },
508+
},
509+
},
510+
});
511+
512+
it('rejects a file larger than the declared maxSize', async () => {
513+
const engine = fakeEngine({
514+
files: [file({ size: 5000 })],
515+
registry: constrained(),
516+
});
517+
install(engine);
518+
519+
await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
520+
FileConstraintError,
521+
);
522+
// The write failed, so nothing was claimed.
523+
expect(engine.tables.sys_file[0].ref_id).toBeUndefined();
524+
});
525+
526+
it('rejects a file whose MIME type is outside the declared accept list', async () => {
527+
const engine = fakeEngine({
528+
files: [file({ mime_type: 'application/pdf', name: 'a.pdf', size: 10 })],
529+
registry: constrained(),
530+
});
531+
install(engine);
532+
533+
await expect(driveInsert(engine, 'product', { image: 'file_a' }, 'p1')).rejects.toThrow(
534+
/not permitted by the accept list/,
535+
);
536+
});
537+
538+
it('accepts a file that satisfies both declarations', async () => {
539+
const engine = fakeEngine({ files: [file({ size: 10 })], registry: constrained() });
540+
install(engine);
541+
542+
await driveInsert(engine, 'product', { image: 'file_a' }, 'p1');
543+
544+
expect(engine.tables.sys_file[0].ref_id).toBe('p1');
545+
});
546+
547+
it.each([
548+
[['image/png'], 'image/png', 'a.png', true],
549+
[['image/*'], 'image/jpeg', 'a.jpg', true],
550+
[['.pdf'], 'application/pdf', 'report.pdf', true],
551+
[['.pdf'], 'application/pdf', 'report.txt', false],
552+
[['image/png'], 'image/jpeg', 'a.jpg', false],
553+
[['*/*'], 'anything/at-all', 'x', true],
554+
])('accept %j vs %s/%s → %s', async (accept, mime, name, allowed) => {
555+
const engine = fakeEngine({
556+
files: [file({ mime_type: mime, name, size: 10 })],
557+
registry: constrained({ accept, maxSize: undefined }),
558+
});
559+
install(engine);
560+
561+
const write = driveInsert(engine, 'product', { image: 'file_a' }, 'p1');
562+
if (allowed) {
563+
await write;
564+
expect(engine.tables.sys_file[0].ref_id).toBe('p1');
565+
} else {
566+
await expect(write).rejects.toThrow(FileConstraintError);
567+
}
568+
});
569+
570+
/**
571+
* Missing metadata is not evidence of a violation — a sys_file row with no
572+
* recorded size or MIME type must not be rejected by a constraint it
573+
* cannot be tested against.
574+
*/
575+
it('does not reject a file whose size or MIME type is unrecorded', async () => {
576+
const engine = fakeEngine({
577+
files: [{ id: 'file_a', key: 'user/file_a', name: 'file_a', status: 'committed' }],
578+
registry: constrained(),
579+
});
580+
install(engine);
581+
582+
await driveInsert(engine, 'product', { image: 'file_a' }, 'p1');
583+
584+
expect(engine.tables.sys_file[0].ref_id).toBe('p1');
585+
});
586+
587+
it('leaves a field with no declared constraints alone', async () => {
588+
const engine = fakeEngine({
589+
files: [file({ mime_type: 'application/pdf', size: 10_000_000 })],
590+
registry: constrained(),
591+
});
592+
install(engine);
593+
594+
// `gallery` declares neither accept nor maxSize.
595+
await driveInsert(engine, 'product', { gallery: ['file_a'] }, 'p1');
596+
597+
expect(engine.tables.sys_file[0].ref_field).toBe('gallery');
598+
});
599+
});
600+
497601
// ── Unknown ids ──────────────────────────────────────────────────
498602
it('leaves an id that matches no sys_file row untouched', async () => {
499603
const engine = fakeEngine({ files: [file()] });

packages/services/service-storage/src/file-reference-lifecycle.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ export class FileReferenceCopyError extends Error {
103103
}
104104
}
105105

106+
/**
107+
* Raised when a referenced file violates its field's declared `accept` /
108+
* `maxSize`. Fails the write: a stored value that breaks its own field's
109+
* declaration is the "declared but not enforced" state ADR-0104 removes.
110+
*/
111+
export class FileConstraintError extends Error {
112+
readonly code = 'ERR_FILE_CONSTRAINT';
113+
constructor(message: string) {
114+
super(message);
115+
}
116+
}
117+
106118
function asIdList(id: unknown): Array<string | number> | null {
107119
if (typeof id === 'string' || typeof id === 'number') return [id];
108120
if (id && typeof id === 'object' && Array.isArray((id as any).$in)) {
@@ -120,6 +132,83 @@ function fileFieldsOf(engine: FileReferenceEngine, objectName: string): string[]
120132
.map(([name]) => name);
121133
}
122134

135+
/** One field's definition, or undefined. */
136+
function fieldDefOf(engine: FileReferenceEngine, objectName: string, field: string): any {
137+
return (engine.getObject(objectName) as any)?.fields?.[field];
138+
}
139+
140+
/**
141+
* Does `mime` satisfy one `accept` entry? Accepts the same vocabulary the file
142+
* picker does: an exact MIME type, a `type/*` wildcard, or a `.ext` suffix
143+
* (matched against the file NAME, since a browser-supplied extension is what
144+
* the author is describing).
145+
*/
146+
function matchesAcceptEntry(entry: string, mime: string, name: string): boolean {
147+
const e = entry.trim().toLowerCase();
148+
if (!e) return false;
149+
if (e === '*' || e === '*/*') return true;
150+
if (e.startsWith('.')) return name.toLowerCase().endsWith(e);
151+
if (e.endsWith('/*')) return mime.startsWith(e.slice(0, -1));
152+
return mime === e;
153+
}
154+
155+
/**
156+
* Enforce a media field's declared `accept` / `maxSize` against the file the
157+
* record is about to reference.
158+
*
159+
* The upload widget checks both before uploading, but that check is a
160+
* convenience rather than a control — any caller talking to the API directly
161+
* bypasses it. Now that the platform owns the file, `sys_file` carries the
162+
* authoritative MIME type and byte size, so the constraint can be re-checked
163+
* where it actually binds. Throws {@link FileConstraintError}, failing the
164+
* write, because a stored value violating its own field's declaration is
165+
* exactly the "declared but not enforced" state ADR-0104 exists to remove.
166+
*
167+
* Only checks what the file actually reports: a `sys_file` row with no
168+
* `mime_type` cannot fail an `accept` test, and one with no `size` cannot fail
169+
* `maxSize`. Missing metadata is not evidence of a violation.
170+
*/
171+
function assertFileConstraints(
172+
fieldDef: any,
173+
field: string,
174+
file: Record<string, unknown>,
175+
): void {
176+
const accept: unknown = fieldDef?.accept;
177+
const maxSize: unknown = fieldDef?.maxSize;
178+
179+
if (typeof maxSize === 'number' && maxSize > 0 && typeof file.size === 'number') {
180+
if (file.size > maxSize) {
181+
throw new FileConstraintError(
182+
`File exceeds the maximum size declared for '${field}' ` +
183+
`(${file.size} bytes > ${maxSize} bytes)`,
184+
);
185+
}
186+
}
187+
188+
if (Array.isArray(accept) && accept.length > 0) {
189+
const mime = typeof file.mime_type === 'string' ? file.mime_type.toLowerCase() : '';
190+
const name = typeof file.name === 'string' ? file.name : '';
191+
const hasExtension = name.includes('.');
192+
193+
// An entry can only be judged against metadata the file actually reports:
194+
// a MIME entry needs a recorded MIME type, an extension entry needs a name
195+
// carrying an extension. Entries that cannot be evaluated are not failures
196+
// — rejecting on them would turn "we don't know" into "not permitted".
197+
const testable = accept.filter((e): e is string => {
198+
if (typeof e !== 'string' || !e.trim()) return false;
199+
return e.trim().startsWith('.') ? hasExtension : !!mime;
200+
});
201+
if (testable.length === 0) return;
202+
203+
if (!testable.some((e) => matchesAcceptEntry(e, mime, name))) {
204+
throw new FileConstraintError(
205+
`File type '${mime || name}' is not permitted by the accept list declared for ` +
206+
`'${field}' (${accept.join(', ')})`,
207+
);
208+
}
209+
}
210+
}
211+
123212
/** The id tokens a field value carries (a `multiple: true` field holds an
124213
* array). Inline blobs and URL-shaped strings yield nothing. */
125214
function idTokensIn(value: unknown): string[] {
@@ -207,9 +296,15 @@ async function copyOwnedFile(
207296

208297
/**
209298
* Rewrite, in place, any id in `data` that is already owned by a different
210-
* slot so it points at a fresh copy instead. Runs in the BEFORE hooks, where
211-
* mutating `input.data` is what the driver goes on to persist — so the record
212-
* never transiently holds a reference it does not own.
299+
* slot so it points at a fresh copy instead, and enforce each referenced
300+
* file's declared `accept` / `maxSize` on the way past. Runs in the BEFORE
301+
* hooks, where mutating `input.data` is what the driver goes on to persist —
302+
* so the record never transiently holds a reference it does not own, and never
303+
* persists one that violates its field's declaration.
304+
*
305+
* The constraint check rides here because this pass already loads every
306+
* referenced `sys_file` row; enforcing it anywhere else would mean a second
307+
* read of the same rows.
213308
*
214309
* `recordId` is null on insert: a new record can never be the current owner,
215310
* so every already-owned id is copied.
@@ -238,6 +333,11 @@ async function applyCopyOnClaim(
238333
// Unknown id: not a file this platform manages (external/legacy value).
239334
// Left verbatim — the read resolver ignores it for the same reason.
240335
if (!row) continue;
336+
337+
// The file is real and about to be referenced by this field, so its
338+
// declared constraints bind now — before ownership, before any copy.
339+
assertFileConstraints(fieldDefOf(engine, object, field), field, row);
340+
241341
// Unclaimed: the after-hook will claim it for this slot. Nothing to copy.
242342
if (row.ref_id == null) continue;
243343
// Already ours (an update rewriting the same value) — no-op.

packages/services/service-storage/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export type { StorageRoutesOptions, FileReadVerdict } from './storage-routes.js'
1414
export { SystemFile, SystemUploadSession } from './objects/index.js';
1515
export { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard } from './attachment-lifecycle.js';
1616
export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js';
17-
export { installFileReferenceHooks, FileReferenceCopyError } from './file-reference-lifecycle.js';
17+
export {
18+
installFileReferenceHooks,
19+
FileReferenceCopyError,
20+
FileConstraintError,
21+
} from './file-reference-lifecycle.js';
1822
export type { FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js';
1923
export {
2024
verifyFileReferences,

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@
341341
"FieldSchema (const)",
342342
"FieldType (type)",
343343
"FileLikeValueSchema (const)",
344+
"FileReferenceIdValueSchema (const)",
344345
"FileValueSchema (const)",
345346
"Filter (type)",
346347
"FilterCondition (type)",

0 commit comments

Comments
 (0)