@@ -12,7 +12,7 @@ import {
1212 type DroppedFieldsEvent
1313} from '@objectstack/spec/data' ;
1414import type { WriteObservabilityOptions } from '@objectstack/spec/contracts' ;
15- import { parseAutonumberFormat , renderAutonumber , missingFieldValues , isTenancyDisabled } from '@objectstack/spec/data' ;
15+ import { parseAutonumberFormat , renderAutonumber , missingFieldValues , isTenancyDisabled , FILE_REFERENCE_TYPES } from '@objectstack/spec/data' ;
1616import { ExecutionContext , ExecutionContextSchema } from '@objectstack/spec/kernel' ;
1717import { IDataDriver , IDataEngine , Logger , createLogger , withTransientRetry , type RetryOptions } from '@objectstack/core' ;
1818import { SummaryRecomputeError , type SummaryRecomputeFailure } from './summary-errors.js' ;
@@ -2120,6 +2120,112 @@ export class ObjectQL implements IDataEngine {
21202120 return records ;
21212121 }
21222122
2123+ /**
2124+ * Whether a value is an opaque `sys_file` id token — the minted uuid/nanoid
2125+ * form (letters, digits, `_`, `-`, bounded length), and crucially NOT a URL:
2126+ * a `https://…` / `/api/…` / `data:…` / `blob:…` file value carries `:`, `/`
2127+ * or `.` and is left untouched (it is a legacy/external URL, not a reference).
2128+ */
2129+ private static readonly FILE_ID_RE = / ^ [ A - Z a - z 0 - 9 _ - ] { 1 , 64 } $ / ;
2130+
2131+ /**
2132+ * Resolve file-field id references to their expanded `FileValueSchema` form
2133+ * (ADR-0104 D3 wave 2). A `file`/`image`/`avatar`/`video`/`audio` value
2134+ * stored as an opaque `sys_file` id string is enriched, in place, to
2135+ * `{ id, name, size, mimeType, url }` — `url` derived from the stable
2136+ * `/files/:fileId` resolver, never stored.
2137+ *
2138+ * DUAL-MODE SAFE: an inline-blob value (an object) and a string that does
2139+ * NOT match a committed `sys_file` row (e.g. an external url) pass through
2140+ * unchanged, so a field may hold either form during the pre-v17 window.
2141+ *
2142+ * Batched: at most one `sys_file` `id $in […]` read per call (no N+1); and
2143+ * zero reads when no field holds a string value (the blob-only case), so the
2144+ * step is free for objects that have not adopted references.
2145+ */
2146+ private async resolveFileReferences (
2147+ objectName : string ,
2148+ records : any [ ] ,
2149+ execCtx ?: ExecutionContext ,
2150+ ) : Promise < any [ ] > {
2151+ if ( ! records || records . length === 0 ) return records ;
2152+ const objectSchema = this . _registry . getObject ( objectName ) ;
2153+ if ( ! objectSchema || ! objectSchema . fields ) return records ;
2154+ // Nothing to resolve against if the file object is not even registered
2155+ // (e.g. the storage plugin is absent) — skip without a failing query.
2156+ if ( ! this . _registry . getObject ( 'sys_file' ) ) return records ;
2157+
2158+ const fileFields = Object . entries ( objectSchema . fields )
2159+ . filter ( ( [ , def ] : [ string , any ] ) => def && FILE_REFERENCE_TYPES . has ( def . type ) )
2160+ . map ( ( [ name ] ) => name ) ;
2161+ if ( fileFields . length === 0 ) return records ;
2162+
2163+ // Collect candidate ids. A file field legitimately holds either an inline
2164+ // blob (an object — already the rich form, skipped) OR, in the dual-mode /
2165+ // legacy world, a URL string (`https://…`, `/api/…`, `data:…`, `blob:…`).
2166+ // Only an OPAQUE id token — the minted uuid/nanoid form, never url-shaped —
2167+ // is a `sys_file` reference to resolve. Filtering out url-shaped strings is
2168+ // what keeps a seeded `data:`/CDN image value from firing a bogus lookup.
2169+ const candidateIds : string [ ] = [ ] ;
2170+ const addCandidate = ( v : unknown ) => {
2171+ if ( typeof v === 'string' && ObjectQL . FILE_ID_RE . test ( v ) ) candidateIds . push ( v ) ;
2172+ } ;
2173+ for ( const record of records ) {
2174+ for ( const fieldName of fileFields ) {
2175+ const val = record [ fieldName ] ;
2176+ if ( val == null ) continue ;
2177+ if ( Array . isArray ( val ) ) {
2178+ for ( const v of val ) addCandidate ( v ) ;
2179+ } else {
2180+ addCandidate ( val ) ;
2181+ }
2182+ }
2183+ }
2184+ const uniqueIds = [ ...new Set ( candidateIds ) ] ;
2185+ if ( uniqueIds . length === 0 ) return records ; // blob-only / empty → zero cost
2186+
2187+ // One batched sys_file read. `__expandRead` mirrors the lookup-expansion
2188+ // sub-read (a system-built marker, never from client input). Metadata only —
2189+ // byte-download authorization is enforced at the /files/:fileId resolver.
2190+ let fileRows : any [ ] = [ ] ;
2191+ try {
2192+ fileRows = ( await this . find (
2193+ 'sys_file' ,
2194+ { where : { id : { $in : uniqueIds } } , context : { ...( execCtx ?? { } ) , __expandRead : true } as ExecutionContext } ,
2195+ ) ) ?? [ ] ;
2196+ } catch {
2197+ return records ; // sys_file unregistered / unreadable — leave ids as-is
2198+ }
2199+
2200+ const fileMap = new Map < string , any > ( ) ;
2201+ for ( const row of fileRows ) {
2202+ if ( row ?. id != null && row . status === 'committed' ) fileMap . set ( String ( row . id ) , row ) ;
2203+ }
2204+ if ( fileMap . size === 0 ) return records ;
2205+
2206+ const toValue = ( row : any ) => ( {
2207+ id : String ( row . id ) ,
2208+ ...( row . name != null ? { name : row . name } : { } ) ,
2209+ ...( row . size != null ? { size : row . size } : { } ) ,
2210+ ...( row . mime_type != null ? { mimeType : row . mime_type } : { } ) ,
2211+ url : `/api/v1/storage/files/${ row . id } ` ,
2212+ } ) ;
2213+
2214+ for ( const record of records ) {
2215+ for ( const fieldName of fileFields ) {
2216+ const val = record [ fieldName ] ;
2217+ if ( val == null ) continue ;
2218+ if ( Array . isArray ( val ) ) {
2219+ record [ fieldName ] = val . map ( ( v : any ) =>
2220+ typeof v === 'string' && fileMap . has ( v ) ? toValue ( fileMap . get ( v ) ) : v ) ;
2221+ } else if ( typeof val === 'string' && fileMap . has ( val ) ) {
2222+ record [ fieldName ] = toValue ( fileMap . get ( val ) ) ;
2223+ }
2224+ }
2225+ }
2226+ return records ;
2227+ }
2228+
21232229 // ============================================
21242230 // Data Access Methods (IDataEngine Interface)
21252231 // ============================================
@@ -2240,7 +2346,14 @@ export class ObjectQL implements IDataEngine {
22402346 if ( ast . expand && Object . keys ( ast . expand ) . length > 0 && Array . isArray ( result ) ) {
22412347 result = await this . expandRelatedRecords ( object , result , ast . expand , 0 , opCtx . context ) ;
22422348 }
2243-
2349+
2350+ // Post-process: resolve file-field id references to their expanded
2351+ // FileValueSchema form (ADR-0104 D3). Always-on but free unless a file
2352+ // field holds an id string; dual-mode-safe (blobs pass through).
2353+ if ( Array . isArray ( result ) ) {
2354+ result = await this . resolveFileReferences ( object , result , opCtx . context ) ;
2355+ }
2356+
22442357 hookContext . event = 'afterFind' ;
22452358 hookContext . result = result ;
22462359 await this . triggerHooks ( 'afterFind' , hookContext ) ;
@@ -2324,6 +2437,12 @@ export class ObjectQL implements IDataEngine {
23242437 result = expanded [ 0 ] ;
23252438 }
23262439
2440+ // Post-process: resolve file-field id references (ADR-0104 D3).
2441+ if ( result != null ) {
2442+ const resolved = await this . resolveFileReferences ( objectName , [ result ] , opCtx . context ) ;
2443+ result = resolved [ 0 ] ;
2444+ }
2445+
23272446 hookContext . event = 'afterFind' ;
23282447 hookContext . result = result ;
23292448 await this . triggerHooks ( 'afterFind' , hookContext ) ;
0 commit comments