@fabrika/db 0.6.0 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/db",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,8 +61,8 @@
61
61
  "test": "bun test"
62
62
  },
63
63
  "dependencies": {
64
- "@fabrika/auth-core": "0.6.0",
65
- "@fabrika/platform-node": "0.6.0",
64
+ "@fabrika/auth-core": "0.8.0",
65
+ "@fabrika/platform-node": "0.8.0",
66
66
  "kysely": "0.29.6",
67
67
  "kysely-postgres-js": "5.0.1",
68
68
  "zod": "^3.25.76"
@@ -1,38 +1,34 @@
1
- // The framework's side of `fabrika_form_admissions` and `fabrika_form_challenges` (`@fabrika/db/migrations`): the
2
- // per-minute counters a form's admission takes from, shared by every instance of the application, and the digests of
3
- // the challenge tokens a form accepted. Only `@fabrika/app`'s form dispatcher reaches them, through
4
- // `frameworkTablesOf`. Each operation is one statement, so two instances never read a counter and then write it, and
5
- // each prunes a bounded batch of expired rows of its own table, so neither table grows past what the last minutes hold.
6
-
7
- import { runFixedStatement, type Session, sessionOf } from './database'
1
+ // The framework's side of `fabrika_form_admissions`, `fabrika_form_challenges` and `fabrika_form_bytes`
2
+ // (`@fabrika/db/migrations`): the per-minute buckets a procedure's admission takes from, shared by every instance of the
3
+ // application, the digests of the challenge tokens it accepted, and its hourly byte budget (`bytesPerHour`). Each is
4
+ // keyed by the endpoint id; the tables keep the names and the `form` column they were created with. Only `@fabrika/app`'s
5
+ // dispatcher reaches them, through `frameworkTablesOf`. Each operation is one statement, so two instances never read a
6
+ // counter and then write it, and each prunes a bounded batch of expired rows of its own table, so no table grows past
7
+ // what the last minutes or hours hold.
8
+
9
+ import { runFixedStatement, sessionOf } from './database'
8
10
  import { QueryRefusedError } from './errors'
9
11
  import { FORM_ADMISSIONS_TABLE, FORM_BYTES_TABLE, FORM_CHALLENGES_TABLE } from './migrations'
10
12
  import type { DataLayerRuntime } from './store'
11
13
 
12
- /** The bucket of `fabrika_form_admissions` an inbox append takes from, in the append's own transaction. */
13
- export interface InboxQuota {
14
- readonly bucket: string
15
- readonly limit: number
16
- }
17
-
18
- export interface FormAdmissions {
14
+ export interface Admissions {
19
15
  /**
20
- * Takes one from `bucket` of `form` in the database's current minute; `false` when `limit` were already taken
16
+ * Takes one from `bucket` of `endpoint` in the database's current minute; `false` when `limit` were already taken
21
17
  * there. The counter and the check are one statement.
22
18
  */
23
- take(form: string, bucket: string, limit: number): Promise<boolean>
19
+ take(endpoint: string, bucket: string, limit: number): Promise<boolean>
24
20
  /**
25
- * Records the digest of a challenge token `form` accepted, kept for `retainMs`; `false` when it was already
21
+ * Records the digest of a challenge token `endpoint` accepted, kept for `retainMs`; `false` when it was already
26
22
  * recorded and has not expired, which makes the token's second use a replay.
27
23
  */
28
- recordChallenge(form: string, tokenDigest: string, retainMs: number): Promise<boolean>
24
+ recordChallenge(endpoint: string, tokenDigest: string, retainMs: number): Promise<boolean>
29
25
  /**
30
- * Reserves `bytes` of `form`'s hourly byte budget `limit`, in the database's current hour; `false`, with nothing
26
+ * Reserves `bytes` of `endpoint`'s hourly byte budget `limit`, in the database's current hour; `false`, with nothing
31
27
  * taken, when they no longer fit. The check and the counter are one statement.
32
28
  */
33
- reserveBytes(form: string, bytes: number, limit: number): Promise<boolean>
34
- /** Gives back `bytes` of a reservation of the current hour that a written file did not need. */
35
- releaseBytes(form: string, bytes: number): Promise<void>
29
+ reserveBytes(endpoint: string, bytes: number, limit: number): Promise<boolean>
30
+ /** Gives back `bytes` of a reservation of the current hour that the request did not need. */
31
+ releaseBytes(endpoint: string, bytes: number): Promise<void>
36
32
  }
37
33
 
38
34
  const A = FORM_ADMISSIONS_TABLE
@@ -85,34 +81,31 @@ function positiveInteger(value: number, what: string): number {
85
81
  return value
86
82
  }
87
83
 
88
- /** One take of `bucket` in `session`, which may be a transaction: rolled back with it, the take is given back. */
89
- export async function takeFromBucket(session: Session, form: string, bucket: string, limit: number): Promise<boolean> {
90
- const rows = await runFixedStatement(session, SQL_TAKE, [form, bucket, positiveInteger(limit, 'a form limit')])
91
- return rows.length > 0
92
- }
93
-
94
- export function formAdmissions(runtime: () => DataLayerRuntime): FormAdmissions {
84
+ export function admissions(runtime: () => DataLayerRuntime): Admissions {
95
85
  const session = () => sessionOf(runtime())
96
86
  return Object.freeze({
97
- take: (form: string, bucket: string, limit: number): Promise<boolean> => takeFromBucket(session(), form, bucket, limit),
87
+ async take(endpoint: string, bucket: string, limit: number): Promise<boolean> {
88
+ const rows = await runFixedStatement(session(), SQL_TAKE, [endpoint, bucket, positiveInteger(limit, 'a bucket limit')])
89
+ return rows.length > 0
90
+ },
98
91
 
99
- async recordChallenge(form: string, tokenDigest: string, retainMs: number): Promise<boolean> {
100
- const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [form, tokenDigest, positiveInteger(retainMs, 'a retention')])
92
+ async recordChallenge(endpoint: string, tokenDigest: string, retainMs: number): Promise<boolean> {
93
+ const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [endpoint, tokenDigest, positiveInteger(retainMs, 'a retention')])
101
94
  return rows.length > 0
102
95
  },
103
96
 
104
- async reserveBytes(form: string, bytes: number, limit: number): Promise<boolean> {
97
+ async reserveBytes(endpoint: string, bytes: number, limit: number): Promise<boolean> {
105
98
  const rows = await runFixedStatement(session(), SQL_RESERVE_BYTES, [
106
- form,
99
+ endpoint,
107
100
  positiveInteger(bytes, 'a reservation'),
108
101
  positiveInteger(limit, 'a byte budget'),
109
102
  ])
110
103
  return rows.length > 0
111
104
  },
112
105
 
113
- async releaseBytes(form: string, bytes: number): Promise<void> {
106
+ async releaseBytes(endpoint: string, bytes: number): Promise<void> {
114
107
  if (bytes > 0) {
115
- await runFixedStatement(session(), SQL_RELEASE_BYTES, [form, positiveInteger(bytes, 'a release')])
108
+ await runFixedStatement(session(), SQL_RELEASE_BYTES, [endpoint, positiveInteger(bytes, 'a release')])
116
109
  }
117
110
  },
118
111
  })
package/src/capability.ts CHANGED
@@ -20,6 +20,14 @@ export interface AppCapability<Action extends string> {
20
20
  readonly '~capability': { readonly kind: 'app'; readonly action: Action }
21
21
  }
22
22
 
23
+ /**
24
+ * One claimed row of one queue table, for one action: what a claim answers as `row.ref`, and what `tx.db.on(row.ref)`
25
+ * finishes the row through. Like a `Ref`, the member is type-only.
26
+ */
27
+ export interface RowRef<Table extends string, Action extends string> {
28
+ readonly '~capability': { readonly kind: 'row'; readonly table: Table; readonly action: Action }
29
+ }
30
+
23
31
  export interface RefGrant {
24
32
  readonly kind: 'ref'
25
33
  readonly dimension: string
@@ -63,11 +71,28 @@ export interface AppGrant {
63
71
  readonly principal: string
64
72
  }
65
73
 
66
- export type CapabilityGrant = RefGrant | WithinGrant | AppGrant
74
+ /** A row a claim leased: the table, its key, and the containment it had then, column by column. */
75
+ export interface RowGrant {
76
+ readonly kind: 'row'
77
+ readonly table: string
78
+ readonly action: string
79
+ readonly principal: string
80
+ readonly id: string
81
+ /** The row's containment columns and their values when it was claimed: a row moved since is out of reach. */
82
+ readonly chain: ReadonlyMap<string, string>
83
+ }
84
+
85
+ export type CapabilityGrant = RefGrant | WithinGrant | AppGrant | RowGrant
67
86
 
68
- /** How a refusal names a capability: `a Ref of note`, `a Within of workspace`, `an app capability`. */
87
+ /** How a refusal names a capability: `a Ref of note`, `a Within of workspace`, `an app capability`, `a row of outbox`. */
69
88
  export function describeGrant(grant: CapabilityGrant): string {
70
- return grant.kind === 'app' ? 'an app capability' : `a ${grant.kind === 'ref' ? 'Ref' : 'Within'} of ${grant.dimension}`
89
+ if (grant.kind === 'app') {
90
+ return 'an app capability'
91
+ }
92
+ if (grant.kind === 'row') {
93
+ return `a row of ${grant.table}`
94
+ }
95
+ return `a ${grant.kind === 'ref' ? 'Ref' : 'Within'} of ${grant.dimension}`
71
96
  }
72
97
 
73
98
  /** One generation of a job's lease, held by one attempt. A write of the attempt commits only while it still holds it. */
@@ -85,6 +110,8 @@ export interface LeaseFence {
85
110
  */
86
111
  export interface CapabilityAuthority {
87
112
  resolve(capability: unknown): CapabilityGrant
113
+ /** Mints the capability of one claimed row, bound to the work running now as every capability is. */
114
+ row(grant: Omit<RowGrant, 'kind'>): unknown
88
115
  /**
89
116
  * The lease the work running now must still hold for a write to commit: the job attempt's, or `null` in a
90
117
  * request. Every mutation checks it inside its own transaction.
@@ -63,7 +63,8 @@ export interface TableShape {
63
63
 
64
64
  export interface JoinShape {
65
65
  readonly shape: TableShape
66
- readonly readAction: string
66
+ /** The child's read actions: a select of the parent joins it only with one of them. */
67
+ readonly readActions: readonly string[]
67
68
  /** The composite foreign key: child column = parent column, for each pair. */
68
69
  readonly on: readonly { readonly childColumn: string; readonly parentColumn: string }[]
69
70
  }
package/src/errors.ts CHANGED
@@ -34,7 +34,7 @@ export class CapabilityError extends Error {
34
34
  export const QUERY_REFUSALS = {
35
35
  invalid_argument: 'a data-layer call took an argument of another shape than its type declares',
36
36
  invalid_value: 'a value cannot be bound: an invalid or out-of-range date, or a non-finite number',
37
- invalid_name: 'a configured name (application, schema, file collection or owner column) breaks its naming rule',
37
+ invalid_name: 'a configured name (application, schema, table or file column) breaks its naming rule',
38
38
  unknown_column: 'a column the table does not declare, or one this operation may not set',
39
39
  unknown_table: 'a table src/db/tables.ts does not declare, or one no store, join or locator reaches',
40
40
  framework_table: "a framework table or a catalog name: application data reaches only the application's own tables",
@@ -55,7 +55,13 @@ export const QUERY_REFUSALS = {
55
55
  sql_function: 'ctx.sql calls a function that is not a reviewed built-in',
56
56
  sql_outside_transaction: 'a job runs ctx.sql only inside ctx.transaction((tx) => …), as tx.sql',
57
57
  sql_scope: 'ctx.sql.scope(…) stands alone, joined by AND, in a WHERE or JOIN … ON clause of its own invocation',
58
- file_scan: "a scan operation on a file collection without scan: true, or a scan verdict outside the scan job's lease",
58
+ file_scan: "a scan verdict outside the scan job's lease",
59
+ claim_site: 'claim leases rows in a transaction of its own: a job calls it on ctx.db, never inside ctx.transaction or a request',
60
+ on_conflict: "an insert's onConflict names no column of the table, a column the insert leaves empty, or another action",
61
+ public_reach: "a public procedure updates, removes, locks or claims no row, and an insert's conflict writes nothing",
62
+ file_value:
63
+ "a file column takes a file reference this request holds and may read (an incoming file, a put, or a read under the table's read action) of the column's kind, or null",
64
+ file_scope: 'a file is referenced from another top-level scope than the one its first reference set',
59
65
  } as const
60
66
 
61
67
  export type QueryRefusal = keyof typeof QUERY_REFUSALS
@@ -0,0 +1,184 @@
1
+ // A table's file columns in the data layer. A read answers a file reference in each one, with the file's metadata
2
+ // loaded in one statement per read; a write takes only a reference its site holds and may read, of the column's kind,
3
+ // and claims, locks and stamps the file in the write's own transaction. The file's row is `fabrika_files`
4
+ // (`stored-files.ts`); the column is a foreign key to it, so a file some row references is never deleted.
5
+
6
+ import { defineValue, ownDescriptor, ownKeys } from './builtins'
7
+ import { runFixedStatement, type Session } from './database'
8
+ import type { Primitive } from './description'
9
+ import { NotFoundError, QueryRefusedError } from './errors'
10
+ import { fileHoldingOf, fileReference, holdsFile } from './file-references'
11
+ import { isPlainIdentifier } from './identifier'
12
+ import type { TableModel } from './policy'
13
+ import { claimStoredFile, lockStoredFile, stampStoredFile, storedFilesMetadata } from './stored-files'
14
+
15
+ /** Where a site's file references belong, and what claiming a pending one needs. */
16
+ export interface FileBinding {
17
+ /** The work a reference of this site belongs to: one request, or one job attempt. */
18
+ readonly holder: object
19
+ /** The id a pending file must carry to be claimed here: the request (or attempt) that staged it. */
20
+ readonly request: string
21
+ /** How long a pending file may wait for its first reference. */
22
+ readonly graceMs: number
23
+ /** Whether files of `kind` wait for a clean scan: a claimed one is `quarantined` until then. */
24
+ scans(kind: string): boolean
25
+ }
26
+
27
+ /** Whether a reference read with `action` lets its holder serve, open and copy the file: the table's or the column's read action. */
28
+ function readsFile(model: TableModel, column: string, action: string): boolean {
29
+ const own = Object.hasOwn(model.columnReads, column) ? model.columnReads[column] : undefined
30
+ return own === undefined ? model.actions.read.includes(action) : own === action
31
+ }
32
+
33
+ /**
34
+ * Puts a file reference in each file column of `rows`, read with `action` in `session`: one statement loads every
35
+ * file's metadata. Without a binding (a store's own runner) the columns keep their ids.
36
+ */
37
+ export async function answerFiles<R>(model: TableModel, binding: FileBinding | undefined, session: Session, rows: R[], action: string): Promise<R[]> {
38
+ const columns = ownKeys(model.files)
39
+ if (binding === undefined || columns.length === 0 || rows.length === 0) {
40
+ return rows
41
+ }
42
+ const ids: string[] = []
43
+ for (const row of rows) {
44
+ for (const column of columns) {
45
+ const value: unknown = typeof row === 'object' && row !== null ? ownDescriptor(row, column)?.value : undefined
46
+ if (typeof value === 'string') {
47
+ ids.push(value)
48
+ }
49
+ }
50
+ }
51
+ const metadata = await storedFilesMetadata(session, ids)
52
+ for (const row of rows) {
53
+ if (typeof row !== 'object' || row === null) {
54
+ continue
55
+ }
56
+ for (const column of columns) {
57
+ const value: unknown = ownDescriptor(row, column)?.value
58
+ if (typeof value !== 'string') {
59
+ continue
60
+ }
61
+ const meta = metadata.get(value)
62
+ if (meta === undefined) {
63
+ throw new QueryRefusedError('unexpected_row', `${model.name}.${column} references a file that is not stored`)
64
+ }
65
+ defineValue(row, column, fileReference(meta, binding.holder, readsFile(model, column, action)))
66
+ }
67
+ }
68
+ return rows
69
+ }
70
+
71
+ /**
72
+ * A write's value for a file column: `null`, or the id of a file reference of the column's kind. A string id, a copied
73
+ * object or a reference of another kind is refused; whether the site holds it is checked where the write runs.
74
+ */
75
+ export function fileValue(model: TableModel, column: string, value: unknown): string | null {
76
+ if (value === null) {
77
+ return null
78
+ }
79
+ const kind = model.files[column]
80
+ const holding = fileHoldingOf(value)
81
+ if (holding === undefined) {
82
+ throw new QueryRefusedError(
83
+ 'file_value',
84
+ `${model.name}.${column} takes a file reference (an input file, a ctx.files.put, or a file column read here) or null, never an id`,
85
+ )
86
+ }
87
+ if (holding.kind !== kind) {
88
+ throw new QueryRefusedError('file_value', `${model.name}.${column} holds files of kind '${kind}', and the reference is of another kind`)
89
+ }
90
+ return holding.id
91
+ }
92
+
93
+ /**
94
+ * The top-level scope a row of `model` lies in, from the values it is written with: `<dimension>:<value>`, or `null` for
95
+ * an application table, which sets no scope.
96
+ */
97
+ export function rowScope(model: TableModel, values: ReadonlyMap<string, Primitive | null>): string | null | undefined {
98
+ const scope = model.scope
99
+ if (scope.mode !== 'dimension' && scope.mode !== 'child') {
100
+ return null
101
+ }
102
+ const top = model.chain[model.chain.length - 1]
103
+ const [dimension, column] = top === undefined
104
+ ? scope.mode === 'dimension' ? [scope.dimension, scope.key] : [undefined, undefined]
105
+ : [top.dimension, top.column]
106
+ if (dimension === undefined || column === undefined) {
107
+ return null
108
+ }
109
+ const value = values.get(column)
110
+ return value === undefined || value === null ? undefined : `${dimension}:${String(value)}`
111
+ }
112
+
113
+ /** The top-level scope of the row whose key is `key`, read in `session`: what an update's file references are stamped with. */
114
+ export async function storedRowScope(model: TableModel, session: Session, key: string, keyValue: string): Promise<string | null> {
115
+ const scope = model.scope
116
+ if (scope.mode !== 'dimension' && scope.mode !== 'child') {
117
+ return null
118
+ }
119
+ const top = model.chain[model.chain.length - 1]
120
+ const dimension = top === undefined ? (scope.mode === 'dimension' ? scope.dimension : undefined) : top.dimension
121
+ const column = top === undefined ? (scope.mode === 'dimension' ? scope.key : undefined) : top.column
122
+ if (dimension === undefined || column === undefined) {
123
+ return null
124
+ }
125
+ if (!isPlainIdentifier(model.name) || !isPlainIdentifier(column) || !isPlainIdentifier(key)) {
126
+ throw new QueryRefusedError('invalid_name', "a file column's table is named by plain identifiers")
127
+ }
128
+ const [found] = await runFixedStatement(session, `select ${column}::text as scope from ${model.name} where ${key}::text = $1`, [keyValue])
129
+ const value: unknown = typeof found === 'object' && found !== null ? ownDescriptor(found, 'scope')?.value : undefined
130
+ if (typeof value !== 'string') {
131
+ throw new NotFoundError(`${model.name} not found`)
132
+ }
133
+ return `${dimension}:${value}`
134
+ }
135
+
136
+ /**
137
+ * The file references of a write, in `session` (its transaction), after its statement: each file is locked `for share`
138
+ * while its state is checked, a pending one this request staged within the grace is claimed, and the first reference
139
+ * from a dimensional table stamps its scope; a reference from another top-level scope is refused.
140
+ */
141
+ export async function referenceFiles(
142
+ model: TableModel,
143
+ binding: FileBinding | undefined,
144
+ session: Session,
145
+ values: ReadonlyMap<string, Primitive | null>,
146
+ scope: () => Promise<string | null>,
147
+ ): Promise<void> {
148
+ const written = ownKeys(model.files).flatMap((column) => {
149
+ const id = values.get(column)
150
+ return typeof id === 'string' ? [{ column, id }] : []
151
+ })
152
+ if (written.length === 0) {
153
+ return
154
+ }
155
+ if (binding === undefined) {
156
+ throw new QueryRefusedError('file_value', `${model.name}: a file column is written only by a handler, through ctx.db`)
157
+ }
158
+ const stamp = await scope()
159
+ for (const { column, id } of written) {
160
+ const kind = model.files[column] ?? ''
161
+ if (!holdsFile(binding.holder, id)) {
162
+ throw new QueryRefusedError('file_value', `${model.name}.${column}: this site holds no reference to that file it may read`)
163
+ }
164
+ const locked = await lockStoredFile(session, id, binding.graceMs)
165
+ if (locked === null) {
166
+ throw new NotFoundError('the file is no longer stored')
167
+ }
168
+ if (locked.kind !== kind) {
169
+ throw new QueryRefusedError('file_value', `${model.name}.${column} holds files of kind '${kind}', and the file is of another kind`)
170
+ }
171
+ if (locked.status === 'pending') {
172
+ if (locked.request !== binding.request || !locked.fresh) {
173
+ throw new QueryRefusedError(
174
+ 'file_value',
175
+ `${model.name}.${column}: a pending file is claimed only by the request that wrote it, within the grace`,
176
+ )
177
+ }
178
+ await claimStoredFile(session, id, binding.scans(kind) ? 'quarantined' : 'live')
179
+ }
180
+ if (stamp !== null && !(await stampStoredFile(session, id, stamp))) {
181
+ throw new QueryRefusedError('file_scope', `${model.name}.${column}: the file belongs to another top-level scope than this row`)
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,60 @@
1
+ // File references: the request-bound values a file column holds. The data layer answers one for each file column of a
2
+ // row it reads, and `@fabrika/app` mints one for a file a request received or put; a file column takes nothing else. A
3
+ // reference is a frozen object of the file's metadata, registered here with the work it belongs to: a reference of
4
+ // another request, or one read under an action that does not read the file, is refused where it is written or served.
5
+
6
+ import { freeze } from './builtins'
7
+ import type { StoredFileMeta, StoredFileStatus } from './stored-files'
8
+
9
+ /** What a handler sees of a stored file: its metadata, never its object. */
10
+ export interface FileReference {
11
+ readonly id: string
12
+ readonly name: string | null
13
+ readonly type: string
14
+ readonly size: number
15
+ readonly status: StoredFileStatus
16
+ }
17
+
18
+ /** Whose a reference is, and what it lets that work do. */
19
+ export interface FileHolding {
20
+ readonly id: string
21
+ readonly kind: string
22
+ /** The work that holds it: one request, or one job attempt. */
23
+ readonly holder: object
24
+ /** Read under an action that reads the file (or received, or put): it may be written into a column, served and opened. */
25
+ readonly readable: boolean
26
+ }
27
+
28
+ // Captured when the module loads, before application code: a later patch to WeakMap does not change the registry.
29
+ const holdings = new WeakMap<object, FileHolding>()
30
+ const readableIds = new WeakMap<object, Set<string>>()
31
+ const apply = Reflect.apply
32
+ const getHolding: (this: WeakMap<object, FileHolding>, key: object) => FileHolding | undefined = WeakMap.prototype.get
33
+ const setHolding: (this: WeakMap<object, FileHolding>, key: object, value: FileHolding) => WeakMap<object, FileHolding> = WeakMap.prototype.set
34
+ const getReadable: (this: WeakMap<object, Set<string>>, key: object) => Set<string> | undefined = WeakMap.prototype.get
35
+ const setReadable: (this: WeakMap<object, Set<string>>, key: object, value: Set<string>) => WeakMap<object, Set<string>> = WeakMap.prototype.set
36
+
37
+ /**
38
+ * A reference to a file for `holder`; only `@fabrika/db` and the framework mint one. A `readable` one also lets its
39
+ * holder write the file into a column, serve it and open it, through any reference to the same file.
40
+ */
41
+ export function fileReference(meta: StoredFileMeta, holder: object, readable: boolean): FileReference {
42
+ const reference: FileReference = freeze({ id: meta.id, name: meta.name, type: meta.type, size: meta.size, status: meta.status })
43
+ apply(setHolding, holdings, [reference, freeze({ id: meta.id, kind: meta.kind, holder, readable })])
44
+ if (readable) {
45
+ const ids = apply(getReadable, readableIds, [holder]) ?? new Set<string>()
46
+ ids.add(meta.id)
47
+ apply(setReadable, readableIds, [holder, ids])
48
+ }
49
+ return reference
50
+ }
51
+
52
+ /** What a reference holds, or `undefined` for any other value: a structural copy, a string id or a forged object. */
53
+ export function fileHoldingOf(value: unknown): FileHolding | undefined {
54
+ return typeof value === 'object' && value !== null ? apply(getHolding, holdings, [value]) : undefined
55
+ }
56
+
57
+ /** Whether `holder` received, put, or read under a read action the file `id`. */
58
+ export function holdsFile(holder: object, id: string): boolean {
59
+ return apply(getReadable, readableIds, [holder])?.has(id) === true
60
+ }