@fabrika/db 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,402 @@
1
+ // The framework's side of `fabrika_files` (`@fabrika/db/migrations`): every stored file, a value a table's file column
2
+ // references by id. `@fabrika/app` stages a file's row before its object, on a connection of its own; the data layer
3
+ // claims it at its first reference, in the handler's transaction, and reads its metadata beside every row; the sweep
4
+ // deletes a file no column references in three steps. Each operation is a fixed statement on the database's clock,
5
+ // reached only through `@fabrika/db/framework`. A statement that names an application table builds it from the
6
+ // table policies' validated identifiers, never from a caller's value.
7
+
8
+ import { freeze } from './builtins'
9
+ import { type FixedParameter, runFixedStatement, type Session, sessionOf } from './database'
10
+ import { DatabaseError, QueryRefusedError } from './errors'
11
+ import { isPlainIdentifier } from './identifier'
12
+ import { fencedTransaction } from './job-leases'
13
+ import { FILE_DELETIONS_TABLE, FILES_TABLE } from './migrations'
14
+ import type { DataLayerRuntime } from './store'
15
+
16
+ const F = FILES_TABLE
17
+ const D = FILE_DELETIONS_TABLE
18
+
19
+ /** A stored file's states: written, waiting for its first reference, for its scan, served, or refused by the scan. */
20
+ export const STORED_FILE_STATUSES = ['pending', 'quarantined', 'live', 'rejected'] as const
21
+
22
+ export type StoredFileStatus = (typeof STORED_FILE_STATUSES)[number]
23
+
24
+ /** What a file reference carries: the file's metadata, never its bytes or its object. */
25
+ export interface StoredFileMeta {
26
+ readonly id: string
27
+ readonly kind: string
28
+ readonly name: string | null
29
+ readonly type: string
30
+ readonly size: number
31
+ readonly status: StoredFileStatus
32
+ }
33
+
34
+ /** A file about to be written: its row comes first, then the object under `objectKey`. */
35
+ export interface StagedFile {
36
+ readonly id: string
37
+ readonly kind: string
38
+ readonly objectKey: string
39
+ readonly contentType: string
40
+ readonly name: string | null
41
+ /** The request (or job attempt) that writes it: only it may claim the file while it is pending. */
42
+ readonly request: string
43
+ }
44
+
45
+ /** A stored file as a byte consumer reads it: only a live one is served, by its scanned version when its kind scans. */
46
+ export interface ServedFile extends StoredFileMeta {
47
+ readonly objectKey: string
48
+ readonly objectVersion: string | null
49
+ }
50
+
51
+ /** What the scan pass needs of a quarantined file. */
52
+ export interface QuarantinedStoredFile {
53
+ readonly id: string
54
+ readonly objectKey: string
55
+ readonly size: number
56
+ readonly sha256: string
57
+ readonly contentType: string
58
+ readonly scanAttempts: number
59
+ readonly dueSince: Date
60
+ }
61
+
62
+ /** A file column the sweep looks a file up in: an application table's column, both validated identifiers. */
63
+ export interface FileColumnRef {
64
+ readonly table: string
65
+ readonly column: string
66
+ }
67
+
68
+ /** What a reference write learns of a file it locked. */
69
+ export interface LockedFile {
70
+ readonly kind: string
71
+ readonly status: StoredFileStatus
72
+ readonly request: string | null
73
+ readonly scope: string | null
74
+ /** Whether it is still within the pending grace, counted from its creation. */
75
+ readonly fresh: boolean
76
+ }
77
+
78
+ export interface StoredFiles {
79
+ /** Commits the `pending` row of a file whose object is written next, on a connection of its own: the cleanup intent. */
80
+ stage(file: StagedFile): Promise<void>
81
+ /** Records the written object's size and digest on its pending row, on a connection of its own. */
82
+ written(id: string, written: { readonly size: number; readonly sha256: string }): Promise<void>
83
+ /**
84
+ * Deletes a pending row this request wrote and nobody claimed, and records its object for deletion, in one
85
+ * statement: the object key, or `null` when the file was claimed (or is gone).
86
+ */
87
+ abandon(id: string, request: string): Promise<string | null>
88
+ /** A file as a byte consumer reads it, in `using` or on its own; `null` when the sweep deleted it. */
89
+ find(id: string, using?: Session): Promise<ServedFile | null>
90
+ /** Quarantines a live file of a scanning kind again when it has no scanned version, so the scan decides once more. */
91
+ requarantine(id: string, version: string | null): Promise<void>
92
+ /** Each file's metadata, by id, in `using`: what the data layer answers in a file column. */
93
+ metadata(using: Session, ids: readonly string[]): Promise<ReadonlyMap<string, StoredFileMeta>>
94
+ // The scan pass, each verdict fenced by the scan job's lease.
95
+ quarantined(kinds: readonly string[], limit: number, now: Date): Promise<readonly QuarantinedStoredFile[]>
96
+ promote(id: string, sha256: string, version: string): Promise<boolean>
97
+ deferScan(id: string, attempts: number, after: Date): Promise<boolean>
98
+ reject(id: string): Promise<boolean>
99
+ // The sweep.
100
+ /** Objects whose rows a sweep deleted and whose own deletion it did not finish. */
101
+ unfinishedDeletions(limit: number): Promise<readonly string[]>
102
+ forgetDeletion(objectKey: string): Promise<void>
103
+ /** Clears the legacy marker of every migrated file that one of `columns` references: it is an ordinary file now. */
104
+ adoptReferenced(columns: readonly FileColumnRef[]): Promise<number>
105
+ /** At most `limit` files past the grace that no column of `columns` references (pending ones included), oldest first. */
106
+ unreferenced(columns: readonly FileColumnRef[], limit: number, graceMs: number): Promise<readonly string[]>
107
+ /**
108
+ * Deletes the row of an unreferenced file and records its object for deletion, in one transaction; `null` when it
109
+ * survived: a reference exists (the foreign key refuses the delete), or it is legacy or within the grace.
110
+ */
111
+ remove(id: string, graceMs: number): Promise<string | null>
112
+ }
113
+
114
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
115
+ /** Postgres' code of a foreign key violation: the delete of a file some column still references. */
116
+ const FOREIGN_KEY_VIOLATION = '23503'
117
+
118
+ const SERVED_COLUMNS = `id::text as id, kind, name, content_type, size, status, object_key, object_version`
119
+
120
+ const SQL_STAGE = `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
121
+ values ($1::uuid, $2, 'pending', $3, $4, $5, $6, now())`
122
+
123
+ const SQL_WRITTEN = `update ${F} set size = $2::bigint, sha256 = $3 where id = $1::uuid and status = 'pending' returning id`
124
+
125
+ const SQL_ABANDON = `with gone as (delete from ${F} where id = $1::uuid and status = 'pending' and request = $2 returning object_key)
126
+ insert into ${D} (object_key, recorded_at) select object_key, now() from gone on conflict (object_key) do nothing returning object_key`
127
+
128
+ const SQL_FIND = `select ${SERVED_COLUMNS} from ${F} where id = $1::uuid`
129
+
130
+ const SQL_REQUARANTINE = `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = now()
131
+ where id = $1::uuid and status = 'live' and object_version is not distinct from $2`
132
+
133
+ const SQL_METADATA = `select id::text as id, kind, name, content_type, size, status from ${F}
134
+ where id = any(string_to_array($1, ',')::uuid[])`
135
+
136
+ const SQL_QUARANTINED = `select id::text as id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
137
+ from ${F} where status = 'quarantined' and kind = any(string_to_array($1, ',')) and (scan_after is null or scan_after < $2::timestamptz)
138
+ order by scan_after nulls first, created_at limit $3::int`
139
+
140
+ const SQL_PROMOTE = `update ${F} set status = 'live', object_version = $3 where id = $1::uuid and status = 'quarantined' and sha256 = $2 returning id`
141
+
142
+ const SQL_DEFER = `update ${F} set scan_attempts = $2::int, scan_after = $3::timestamptz
143
+ where id = $1::uuid and status = 'quarantined' and scan_attempts = $2::int - 1 returning id`
144
+
145
+ const SQL_REJECT = `update ${F} set status = 'rejected' where id = $1::uuid and status = 'quarantined' returning id`
146
+
147
+ const SQL_UNFINISHED = `select object_key from ${D} order by recorded_at limit $1::int`
148
+
149
+ const SQL_FORGET = `delete from ${D} where object_key = $1`
150
+
151
+ const SQL_REMOVE = `with gone as (
152
+ delete from ${F} where id = $1::uuid and legacy is null and created_at < now() - $2::bigint * interval '1 millisecond' returning object_key
153
+ )
154
+ insert into ${D} (object_key, recorded_at) select object_key, now() from gone returning object_key`
155
+
156
+ /** The lock a reference write takes: a file being deleted cannot gain a reference, and a referenced one is not deleted. */
157
+ const SQL_LOCK = `select kind, status, request, scope, created_at > now() - $2::bigint * interval '1 millisecond' as fresh
158
+ from ${F} where id = $1::uuid for share`
159
+
160
+ const SQL_CLAIM = `update ${F} set status = $2 where id = $1::uuid and status = 'pending' returning id`
161
+
162
+ /** The first dimensional reference stamps the scope; one from another top-level scope matches nothing. */
163
+ const SQL_STAMP = `update ${F} set scope = $2 where id = $1::uuid and (scope is null or scope = $2) returning id`
164
+
165
+ /** `exists (select 1 from <table> where <column> = f.id)`, one per column, over the policies' validated identifiers. */
166
+ function referencedBy(columns: readonly FileColumnRef[]): string {
167
+ if (columns.length === 0) {
168
+ return 'false'
169
+ }
170
+ return columns.map(({ table, column }) => {
171
+ if (!isPlainIdentifier(table) || !isPlainIdentifier(column)) {
172
+ throw new QueryRefusedError('invalid_name', 'a file column is named by plain identifiers')
173
+ }
174
+ return `exists (select 1 from ${table} r where r.${column} = f.id)`
175
+ }).join(' or ')
176
+ }
177
+
178
+ function uuid(value: string): string {
179
+ if (!UUID.test(value)) {
180
+ throw new QueryRefusedError('invalid_argument', 'a stored file is named by its UUID')
181
+ }
182
+ return value
183
+ }
184
+
185
+ function positive(value: number, what: string): number {
186
+ if (!Number.isSafeInteger(value) || value < 1) {
187
+ throw new QueryRefusedError('invalid_argument', `${what} must be a whole number of at least 1`)
188
+ }
189
+ return value
190
+ }
191
+
192
+ function record(row: unknown): Readonly<Record<string, unknown>> {
193
+ if (typeof row !== 'object' || row === null) {
194
+ throw new QueryRefusedError('unexpected_row', `${F} answered a row that is not an object`)
195
+ }
196
+ return Object.fromEntries(Object.entries(row))
197
+ }
198
+
199
+ function text(row: Readonly<Record<string, unknown>>, column: string): string {
200
+ const value = row[column]
201
+ if (typeof value !== 'string') {
202
+ throw new QueryRefusedError('unexpected_row', `${F}.${column} came back as ${typeof value}`)
203
+ }
204
+ return value
205
+ }
206
+
207
+ function nullableText(row: Readonly<Record<string, unknown>>, column: string): string | null {
208
+ const value = row[column]
209
+ return typeof value === 'string' ? value : null
210
+ }
211
+
212
+ /** BIGINT and INTEGER come back as strings or numbers; a pending row has no size yet, which reads as 0. */
213
+ function count(row: Readonly<Record<string, unknown>>, column: string): number {
214
+ const value = row[column] === null ? 0 : Number(row[column])
215
+ if (!Number.isSafeInteger(value) || value < 0) {
216
+ throw new QueryRefusedError('unexpected_row', `${F}.${column} is not a non-negative integer`)
217
+ }
218
+ return value
219
+ }
220
+
221
+ function status(row: Readonly<Record<string, unknown>>): StoredFileStatus {
222
+ const value = row['status']
223
+ const found = STORED_FILE_STATUSES.find((candidate) => candidate === value)
224
+ if (found === undefined) {
225
+ throw new QueryRefusedError('unexpected_row', `${F}.status is not a file state`)
226
+ }
227
+ return found
228
+ }
229
+
230
+ function date(row: Readonly<Record<string, unknown>>, column: string): Date {
231
+ const value = row[column]
232
+ if (!(value instanceof Date)) {
233
+ throw new QueryRefusedError('unexpected_row', `${F}.${column} came back as ${typeof value}`)
234
+ }
235
+ return value
236
+ }
237
+
238
+ function metaOf(row: Readonly<Record<string, unknown>>): StoredFileMeta {
239
+ return freeze({
240
+ id: text(row, 'id'),
241
+ kind: text(row, 'kind'),
242
+ name: nullableText(row, 'name'),
243
+ type: text(row, 'content_type'),
244
+ size: count(row, 'size'),
245
+ status: status(row),
246
+ })
247
+ }
248
+
249
+ /** Locks file `id` `for share` in `session`, the transaction that writes a reference to it; `null` when it is gone. */
250
+ export async function lockStoredFile(session: Session, id: string, graceMs: number): Promise<LockedFile | null> {
251
+ const [found] = await runFixedStatement(session, SQL_LOCK, [uuid(id), positive(graceMs, 'a pending grace')])
252
+ if (found === undefined) {
253
+ return null
254
+ }
255
+ const row = record(found)
256
+ return freeze({
257
+ kind: text(row, 'kind'),
258
+ status: status(row),
259
+ request: nullableText(row, 'request'),
260
+ scope: nullableText(row, 'scope'),
261
+ fresh: row['fresh'] === true,
262
+ })
263
+ }
264
+
265
+ /** Ends the pending state of a file the reference write locked: `quarantined` for a scanning kind, else `live`. */
266
+ export async function claimStoredFile(session: Session, id: string, ended: 'quarantined' | 'live'): Promise<void> {
267
+ const rows = await runFixedStatement(session, SQL_CLAIM, [uuid(id), ended])
268
+ if (rows.length !== 1) {
269
+ throw new QueryRefusedError('unexpected_row', 'a pending file changed under its lock')
270
+ }
271
+ }
272
+
273
+ /** Sets the scope of a file's first dimensional reference; `false` when it already holds another. */
274
+ export async function stampStoredFile(session: Session, id: string, scope: string): Promise<boolean> {
275
+ return (await runFixedStatement(session, SQL_STAMP, [uuid(id), scope])).length === 1
276
+ }
277
+
278
+ /** Each file's metadata, by id, in `using`: one statement for every file column of a read. */
279
+ export async function storedFilesMetadata(using: Session, ids: readonly string[]): Promise<ReadonlyMap<string, StoredFileMeta>> {
280
+ const unique = [...new Set(ids)]
281
+ if (unique.length === 0) {
282
+ return new Map()
283
+ }
284
+ const rows = await runFixedStatement(using, SQL_METADATA, [unique.map(uuid).join(',')])
285
+ return new Map(rows.map((found) => {
286
+ const meta = metaOf(record(found))
287
+ return [meta.id, meta] as const
288
+ }))
289
+ }
290
+
291
+ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
292
+ const session = () => sessionOf(runtime())
293
+ async function fenced(statement: string, parameters: readonly FixedParameter[]): Promise<boolean> {
294
+ const current = runtime()
295
+ const fence = current.capabilities.fence()
296
+ if (fence === null) {
297
+ throw new QueryRefusedError('file_scan', "a scan verdict is written only inside the scan job's lease")
298
+ }
299
+ const rows = await fencedTransaction(sessionOf(current), fence, (transaction) => runFixedStatement(transaction, statement, parameters))
300
+ return rows.length > 0
301
+ }
302
+ return freeze({
303
+ async stage(file: StagedFile): Promise<void> {
304
+ await runFixedStatement(session(), SQL_STAGE, [uuid(file.id), file.kind, file.objectKey, file.contentType, file.name, file.request])
305
+ },
306
+
307
+ async written(id: string, written: { readonly size: number; readonly sha256: string }): Promise<void> {
308
+ if (!/^[0-9a-f]{64}$/.test(written.sha256) || !Number.isSafeInteger(written.size) || written.size < 0) {
309
+ throw new QueryRefusedError('invalid_argument', 'a written file records its size and the hex SHA-256 of its bytes')
310
+ }
311
+ const rows = await runFixedStatement(session(), SQL_WRITTEN, [uuid(id), String(written.size), written.sha256])
312
+ if (rows.length !== 1) {
313
+ throw new QueryRefusedError('unexpected_row', 'a staged file is no longer pending')
314
+ }
315
+ },
316
+
317
+ async abandon(id: string, request: string): Promise<string | null> {
318
+ const [found] = await runFixedStatement(session(), SQL_ABANDON, [uuid(id), request])
319
+ return found === undefined ? null : text(record(found), 'object_key')
320
+ },
321
+
322
+ async find(id: string, using?: Session): Promise<ServedFile | null> {
323
+ if (!UUID.test(id)) {
324
+ return null
325
+ }
326
+ const [found] = await runFixedStatement(using ?? session(), SQL_FIND, [id])
327
+ if (found === undefined) {
328
+ return null
329
+ }
330
+ const row = record(found)
331
+ return freeze({ ...metaOf(row), objectKey: text(row, 'object_key'), objectVersion: nullableText(row, 'object_version') })
332
+ },
333
+
334
+ async requarantine(id: string, version: string | null): Promise<void> {
335
+ await runFixedStatement(session(), SQL_REQUARANTINE, [uuid(id), version])
336
+ },
337
+
338
+ metadata: storedFilesMetadata,
339
+
340
+ async quarantined(kinds: readonly string[], limit: number, now: Date): Promise<readonly QuarantinedStoredFile[]> {
341
+ if (kinds.length === 0) {
342
+ return []
343
+ }
344
+ const rows = await runFixedStatement(session(), SQL_QUARANTINED, [kinds.join(','), now.toISOString(), positive(limit, 'a scan batch')])
345
+ return rows.map((found) => {
346
+ const row = record(found)
347
+ return freeze({
348
+ id: text(row, 'id'),
349
+ objectKey: text(row, 'object_key'),
350
+ size: count(row, 'size'),
351
+ sha256: text(row, 'sha256'),
352
+ contentType: text(row, 'content_type'),
353
+ scanAttempts: count(row, 'scan_attempts'),
354
+ dueSince: date(row, 'due_since'),
355
+ })
356
+ })
357
+ },
358
+
359
+ promote: (id: string, sha256: string, version: string) => fenced(SQL_PROMOTE, [uuid(id), sha256, version]),
360
+
361
+ deferScan: (id: string, attempts: number, after: Date) => fenced(SQL_DEFER, [uuid(id), positive(attempts, 'a scan attempt'), after.toISOString()]),
362
+
363
+ reject: (id: string) => fenced(SQL_REJECT, [uuid(id)]),
364
+
365
+ async unfinishedDeletions(limit: number): Promise<readonly string[]> {
366
+ const rows = await runFixedStatement(session(), SQL_UNFINISHED, [positive(limit, 'a sweep batch')])
367
+ return rows.map((found) => text(record(found), 'object_key'))
368
+ },
369
+
370
+ async forgetDeletion(objectKey: string): Promise<void> {
371
+ await runFixedStatement(session(), SQL_FORGET, [objectKey])
372
+ },
373
+
374
+ async adoptReferenced(columns: readonly FileColumnRef[]): Promise<number> {
375
+ if (columns.length === 0) {
376
+ return 0
377
+ }
378
+ const statement = `update ${F} f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
379
+ return (await runFixedStatement(session(), statement, [])).length
380
+ },
381
+
382
+ async unreferenced(columns: readonly FileColumnRef[], limit: number, graceMs: number): Promise<readonly string[]> {
383
+ const statement = `select f.id::text as id from ${F} f
384
+ where f.legacy is null and f.created_at < now() - $1::bigint * interval '1 millisecond' and not (${referencedBy(columns)})
385
+ order by f.created_at limit $2::int`
386
+ const rows = await runFixedStatement(session(), statement, [positive(graceMs, 'a pending grace'), positive(limit, 'a sweep batch')])
387
+ return rows.map((found) => text(record(found), 'id'))
388
+ },
389
+
390
+ async remove(id: string, graceMs: number): Promise<string | null> {
391
+ try {
392
+ const [found] = await runFixedStatement(session(), SQL_REMOVE, [uuid(id), positive(graceMs, 'a pending grace')])
393
+ return found === undefined ? null : text(record(found), 'object_key')
394
+ } catch (error) {
395
+ if (error instanceof DatabaseError && error.sqlState === FOREIGN_KEY_VIOLATION) {
396
+ return null
397
+ }
398
+ throw error
399
+ }
400
+ },
401
+ })
402
+ }
package/src/tables.ts CHANGED
@@ -1,28 +1,38 @@
1
1
  // @fabrika/db/tables — the table policies of `src/db/tables.ts` as data. Browser- and Worker-safe: no Bun API and no
2
2
  // driver, so `@fabrika/app/contract` and the static contract reader in `@fabrika/check` import it.
3
3
 
4
- import type { ColumnPolicyArtifact, TablePolicyArtifact, TableScope } from '@fabrika/auth-core'
4
+ import type { ColumnPolicyArtifact, TableActionArtifact, TablePolicyArtifact, TableScope } from '@fabrika/auth-core'
5
5
  import { freeze, namesOf, ownEntry, ownKeys, setOwn } from './builtins'
6
+ import { PolicyError } from './errors'
6
7
  import { modelOf, type ScopeModel, type TableModel, tableModels, type Tables } from './policy'
7
8
 
8
9
  export { PolicyError } from './errors'
10
+ export type { FileReference } from './file-references'
9
11
  export type {
10
12
  AnyDbIndex,
11
13
  ApplicationRows,
12
14
  ApplicationTables,
13
15
  AppRecordRows,
14
16
  ChildRows,
17
+ ClaimedRow,
18
+ ClaimedRowHandle,
19
+ ClaimOptions,
15
20
  ContainedRows,
16
21
  CountOptions,
17
22
  DataKind,
18
23
  DbIndex,
19
24
  FragmentTable,
20
25
  IndexedTable,
26
+ InsertConflict,
27
+ InsertMethod,
21
28
  ListOptions,
22
29
  ListPage,
23
30
  NoUnscoped,
24
31
  OrderTerm,
25
32
  OwnerRow,
33
+ PublicApplicationRows,
34
+ PublicApplicationTables,
35
+ PublicInsertMethod,
26
36
  ReadOnlyUnscoped,
27
37
  ReferenceRows,
28
38
  ReferenceTables,
@@ -55,13 +65,14 @@ export {
55
65
  defineTables,
56
66
  type DimensionPolicy,
57
67
  type DimensionScope,
58
- type InboxPolicy,
59
- type InboxScope,
68
+ type FilePolicies,
60
69
  isTables,
70
+ type QueuePolicy,
61
71
  readTables,
62
72
  type RecordActions,
63
73
  type ReferencePolicy,
64
74
  type ReferenceScope,
75
+ type TableAction,
65
76
  type TableActions,
66
77
  type TableInsert,
67
78
  Tables,
@@ -102,23 +113,6 @@ export function tablePolicies<DB, P>(tables: Tables<DB, P>): TablePolicies {
102
113
  return freeze(artifacts)
103
114
  }
104
115
 
105
- /** Each form inbox's columns, by table: the only fields its form writes. The artifact carries no column list. */
106
- export type InboxColumns = Readonly<Record<string, readonly string[]>>
107
-
108
- /** The columns of every inbox table `defineTables` (or `readTables`) checked. */
109
- export function inboxColumns<DB, P>(tables: Tables<DB, P>): InboxColumns {
110
- const models = tableModels(tables)
111
- const names = ownKeys(models)
112
- const columns: Record<string, readonly string[]> = {}
113
- for (let index = 0; index < names.length; index++) {
114
- const model = modelOf(models, names[index] ?? '')
115
- if (model?.scope.mode === 'inbox') {
116
- setOwn(columns, model.name, freeze([...namesOf(model.columns)]))
117
- }
118
- }
119
- return freeze(columns)
120
- }
121
-
122
116
  function artifactOf(model: TableModel): TablePolicyArtifact {
123
117
  const columns: Record<string, ColumnPolicyArtifact> = {}
124
118
  const declared = [...ownKeys(model.columnReads), ...ownKeys(model.columnWrites)]
@@ -131,10 +125,10 @@ function artifactOf(model: TableModel): TablePolicyArtifact {
131
125
  return {
132
126
  scope: scopeOf(model.scope),
133
127
  actions: {
134
- read: model.actions.read,
135
- insert: model.actions.insert ?? null,
136
- update: model.actions.update ?? null,
137
- delete: model.actions.delete ?? null,
128
+ read: actionArtifact(model.actions.read) ?? noReadAction(model.name),
129
+ insert: actionArtifact(model.actions.insert),
130
+ update: actionArtifact(model.actions.update),
131
+ delete: actionArtifact(model.actions.delete),
138
132
  ...(model.actions.appRead.length === 0 ? {} : { appRead: [...model.actions.appRead] }),
139
133
  ...(model.actions.appCount.length === 0 ? {} : { appCount: [...model.actions.appCount] }),
140
134
  },
@@ -143,9 +137,23 @@ function artifactOf(model: TableModel): TablePolicyArtifact {
143
137
  version: model.version ?? null,
144
138
  createdBy: model.createdBy ?? null,
145
139
  columns,
140
+ ...(ownKeys(model.files).length === 0 ? {} : { files: { ...model.files } }),
146
141
  }
147
142
  }
148
143
 
144
+ /** One action as its text, so a single-action policy keeps the artifact and digest it had; several as a list; none as `null`. */
145
+ function actionArtifact(actions: readonly string[]): TableActionArtifact | null {
146
+ const [first] = actions
147
+ if (first === undefined) {
148
+ return null
149
+ }
150
+ return actions.length === 1 ? first : [...actions]
151
+ }
152
+
153
+ function noReadAction(table: string): never {
154
+ throw new PolicyError(`${table}: the policy needs a read action`)
155
+ }
156
+
149
157
  function scopeOf(scope: ScopeModel): TableScope {
150
158
  switch (scope.mode) {
151
159
  case 'dimension':
@@ -159,8 +167,6 @@ function scopeOf(scope: ScopeModel): TableScope {
159
167
  return { mode: 'child', parent: scope.parent, key: scope.key, column: scope.column, inherit: [...scope.inherit] }
160
168
  case 'reference':
161
169
  return { mode: 'reference', reason: scope.reason }
162
- case 'inbox':
163
- return { mode: 'inbox', form: scope.form }
164
170
  case 'app':
165
171
  return { mode: 'app', reason: scope.reason, key: scope.key }
166
172
  }
package/src/testing.ts CHANGED
@@ -20,13 +20,12 @@ import { inSet, namesOf } from './builtins'
20
20
  import { type CatalogColumn, introspectSchema } from './codegen'
21
21
  import { applicationSchema, Database, openFirstConnection } from './database'
22
22
  import type { Primitive } from './description'
23
- import { FRAMEWORK_MIGRATION_BUNDLE, FRAMEWORK_MIGRATIONS } from './migrations'
23
+ import { FILES_TABLE, FRAMEWORK_MIGRATION_BUNDLE, FRAMEWORK_MIGRATIONS } from './migrations'
24
24
  import { type Container, type ForeignKey, modelOf, type TableModel, type TableModels, tableModels, type Tables } from './policy'
25
25
  import { refuseInProduction } from './production'
26
26
  import { frameworkSideOf } from './registry'
27
27
  import type { TableProbe } from './store'
28
28
 
29
- export { withoutInboxes } from './policy'
30
29
  export { ChildTable, defineChildStore, defineStore, DimensionTable, type TableProbe } from './store'
31
30
 
32
31
  const MODULE = '@fabrika/db/testing'
@@ -133,8 +132,13 @@ export interface TestDatabase {
133
132
  * the markers it holds.
134
133
  */
135
134
  seedUnder<DB, P>(tables: Tables<DB, P>, table: string, under: WorldRow): Promise<SeededRow>
136
- /** Values for the policy's insert columns of `table` that its schema accepts: every required one, sampled. */
135
+ /** Values for the policy's insert columns of `table` that its schema accepts: every required one, sampled; a file column a seeded file. */
137
136
  sampleInsert<DB, P>(tables: Tables<DB, P>, table: string): Promise<Readonly<Record<string, SampleValue>>>
137
+ /**
138
+ * The object key of every file the fixture seeded into a file column, oldest first. Each stands for an empty object,
139
+ * which a test's object store holds when a handler serves or opens the file.
140
+ */
141
+ seededObjects(): readonly string[]
138
142
  /** Closes the connections and drops the schema and its roles. */
139
143
  close(): Promise<void>
140
144
  }
@@ -258,6 +262,14 @@ function ownerOf(models: TableModels, dimension: string): TableModel | undefined
258
262
 
259
263
  /** How many referenced rows deep the fixture follows required foreign keys outside the chain before it gives up. */
260
264
  const MAX_REFERENCE_DEPTH = 4
265
+ /** The digest of the empty object a seeded file stands for. */
266
+ const EMPTY_SHA256 = createHash('sha256').digest('hex')
267
+
268
+ /**
269
+ * The object version a seeded file was scanned at: whoever stocks its empty object (`seededObjects`) stores it under
270
+ * this version, so a file of a scanning kind is served as a live one is, by its scanned version.
271
+ */
272
+ export const SEEDED_OBJECT_VERSION = '"seeded"'
261
273
 
262
274
  /** A CHECK or foreign key violation of a seeded row, as the generator limit it is; `undefined` for any other failure. */
263
275
  function seedLimit(error: unknown, table: string): SeedLimitError | undefined {
@@ -375,6 +387,7 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
375
387
  }
376
388
  const { schema, lock, seeder, catalog } = opened
377
389
  let checks: Promise<readonly CheckConstraint[]> | undefined
390
+ const seededObjects: string[] = []
378
391
 
379
392
  /** The CHECK constraints that name `column` of `table`. */
380
393
  async function checksOn(table: string, column: string): Promise<readonly CheckConstraint[]> {
@@ -475,8 +488,21 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
475
488
  throw new Error(`${table}.${column} holds a ${dimension} as ${type}; a key or containment column holds its scope value as text or uuid`)
476
489
  }
477
490
 
491
+ /** A live, empty file of `kind`, as the framework's put leaves one once a reference claims it: what a seeded file column holds. */
492
+ async function seedFile(kind: string): Promise<string> {
493
+ const id = crypto.randomUUID()
494
+ const objectKey = `${kind}/${id}`
495
+ seededObjects.push(objectKey)
496
+ await seeder.unsafe(
497
+ `INSERT INTO ${quoted(schema)}.${quoted(FILES_TABLE)} (id, kind, status, object_key, size, sha256, content_type, name, created_at, object_version)
498
+ VALUES ($1, $2, 'live', $3, 0, $4, 'application/octet-stream', 'seeded', now(), $5)`,
499
+ [id, kind, objectKey, EMPTY_SHA256, SEEDED_OBJECT_VERSION],
500
+ )
501
+ return id
502
+ }
503
+
478
504
  /**
479
- * Inserts one row: `assigned` as given, a foreign key outside the chain `null` or referencing a row, a column a CHECK
505
+ * Inserts one row: `assigned` as given, a file column a seeded file, a foreign key outside the chain `null` or referencing a row, a column a CHECK
480
506
  * names its `IN` list's first value or `null`, every other text column a marker and every other required column a
481
507
  * sample. Answers what the fixture wrote, and the row as stored (its database defaults filled). A CHECK or a foreign
482
508
  * key the values still fail is a `SeedLimitError` that names the constraint.
@@ -508,8 +534,11 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
508
534
  continue
509
535
  }
510
536
  if (column.generated !== 'no') continue
511
- const checked = await checkedValue(table, column)
512
- if (checked !== undefined) {
537
+ const kind = model !== undefined && Object.hasOwn(model.files, name) ? model.files[name] : undefined
538
+ const checked = kind === undefined ? await checkedValue(table, column) : undefined
539
+ if (kind !== undefined) {
540
+ row.set(name, await seedFile(kind))
541
+ } else if (checked !== undefined) {
513
542
  row.set(name, checked)
514
543
  } else if (await holdsMarker(model, table, column)) {
515
544
  const marker = `m${randomHex()}${randomHex()}`
@@ -666,10 +695,13 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
666
695
  for (const [name, value] of await referencedKey(models, table, key, open, 0)) values[name] = value
667
696
  }
668
697
  for (const column of required) {
669
- if (!(column.column_name in values)) values[column.column_name] = await requiredSample(table, column)
698
+ const name = column.column_name
699
+ const kind = Object.hasOwn(model.files, name) ? model.files[name] : undefined
700
+ if (!(name in values)) values[name] = kind === undefined ? await requiredSample(table, column) : await seedFile(kind)
670
701
  }
671
702
  return values
672
703
  },
704
+ seededObjects: () => [...seededObjects],
673
705
  // Its own connections end first, so the drop finds a slot even in a database at its limit.
674
706
  close: () => closeTestDatabase(url, opened, (drop) => withConnectionCapacity(drop, { waitMs })),
675
707
  }
@@ -1,7 +1,7 @@
1
1
  // The table policies of the vocabulary form, `defineTables(vocabulary, columns)`: how an application writes them, with
2
2
  // containment taken from the vocabulary, and the normalized policies the kit and the handles read. Types only.
3
3
 
4
- import type { ColumnList, ColumnPolicies, InboxPolicy, RecordActions, ReferencePolicy, TableActions, Tables, VersionColumn } from './policy'
4
+ import type { ColumnList, ColumnPolicies, FilePolicies, RecordActions, ReferencePolicy, TableActions, Tables, VersionColumn } from './policy'
5
5
 
6
6
  declare const databaseType: unique symbol
7
7
 
@@ -74,6 +74,8 @@ interface WritableFields<Col extends string, A extends string, Ver extends Col,
74
74
  readonly version?: Ver
75
75
  readonly createdBy?: Col
76
76
  readonly columns?: ColumnPolicies<Col, A>
77
+ /** The file columns, each with the file kind it holds (`c.files`): a `uuid` foreign key to `fabrika_files`. */
78
+ readonly files?: FilePolicies<Col>
77
79
  }
78
80
 
79
81
  /**
@@ -131,20 +133,18 @@ type AuthoredApplicationPolicy<Col extends string, A extends string, Ver extends
131
133
 
132
134
  /**
133
135
  * One table's policy as an application writes it under a vocabulary. `S` is its scope as written: its mode picks the
134
- * one policy form a mistake is reported against, rather than a union of all five. `P` is every policy as written.
136
+ * one policy form a mistake is reported against, rather than a union of all four. `P` is every policy as written.
135
137
  */
136
138
  export type VocabularyTablePolicy<Table extends string, Col extends string, A extends string, V, Ver extends Col, S = unknown, P = unknown> =
137
139
  S extends { readonly dimension: unknown } ? AuthoredDimensionPolicy<Col, A, V, Ver, S>
138
140
  : S extends { readonly child: unknown } ? AuthoredChildPolicy<Table, Col, A, Ver, S, P>
139
141
  : S extends { readonly app: unknown } ? AuthoredApplicationPolicy<Col, A, Ver>
140
142
  : S extends { readonly reference: unknown } ? ReferencePolicy<Col, A>
141
- : S extends { readonly inbox: unknown } ? InboxPolicy<Col, A>
142
143
  :
143
144
  | AuthoredDimensionPolicy<Col, A, V, Ver, S>
144
145
  | AuthoredChildPolicy<Table, Col, A, Ver, S, P>
145
146
  | AuthoredApplicationPolicy<Col, A, Ver>
146
147
  | ReferencePolicy<Col, A>
147
- | InboxPolicy<Col, A>
148
148
 
149
149
  type StatedScope<P, T> = T extends keyof P ? P[T] extends { readonly scope: infer S } ? S : unknown : unknown
150
150