@fabrika/db 0.5.4 → 0.6.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.5.4",
3
+ "version": "0.6.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.5.4",
65
- "@fabrika/platform-node": "0.5.4",
64
+ "@fabrika/auth-core": "0.6.0",
65
+ "@fabrika/platform-node": "0.6.0",
66
66
  "kysely": "0.29.6",
67
67
  "kysely-postgres-js": "5.0.1",
68
68
  "zod": "^3.25.76"
@@ -53,6 +53,18 @@ export interface PendingFile {
53
53
  readonly createdAt: Date
54
54
  }
55
55
 
56
+ /** A file whose object was written before it had an owner (a form's): `adopt` gives it one. */
57
+ export interface AdoptedFile {
58
+ readonly id: string
59
+ readonly objectKey: string
60
+ readonly size: number
61
+ readonly sha256: string
62
+ readonly contentType: string
63
+ readonly originalName: string | null
64
+ /** When its object was received. */
65
+ readonly createdAt: Date
66
+ }
67
+
56
68
  /** A file whose upload completed: served when `live`, `409` while `quarantined`, gone when `rejected`. */
57
69
  export type HeldStatus = Exclude<FileStatus, 'pending'>
58
70
 
@@ -112,8 +124,16 @@ export interface FileCollectionTable {
112
124
  complete(owner: unknown, id: string, written: { readonly size: number; readonly sha256: string }, status: 'live' | 'quarantined'): Promise<void>
113
125
  /** Deletes the caller's own pending row once its object is gone. */
114
126
  abandon(id: string): Promise<void>
127
+ /**
128
+ * Locks the owner where `owner` (a `Ref` for the write action) says it lies and inserts a held row for a file whose
129
+ * object already exists, in `using`: `quarantined` in a scanning collection, so the scan job decides about it, else
130
+ * `live`. The owner's held files are counted under that lock, so two adoptions never both pass `maxPerOwner`
131
+ * (`null`: no limit); `limit`, with nothing inserted, when the owner holds that many. `NotFoundError` when the owner
132
+ * is not there.
133
+ */
134
+ adopt(owner: unknown, file: AdoptedFile, using: Session, maxPerOwner: number | null): Promise<'adopted' | 'limit'>
115
135
  /** The owner's files that hold a place in its quota: live, quarantined and rejected, until removed. */
116
- countHeld(owner: unknown): Promise<number>
136
+ countHeld(owner: unknown, using?: Session): Promise<number>
117
137
  /** Marks a held file of `owner` removed. */
118
138
  retract(owner: unknown, id: string, at: Date): Promise<void>
119
139
  /**
@@ -381,14 +401,39 @@ function collectionTable(models: TableModels, runtime: () => DataLayerRuntime, s
381
401
  await session().run(deleteNode(table, where, 'id'), guard('DeleteQueryNode', table, scope))
382
402
  },
383
403
 
384
- async countHeld(capability: unknown): Promise<number> {
404
+ async adopt(capability: unknown, file: AdoptedFile, transaction: Session, maxPerOwner: number | null): Promise<'adopted' | 'limit'> {
405
+ const grant = grantOf(capability, owner.dimension, spec.actions.write)
406
+ const values = new Map<string, string | number | Date | null>([['id', file.id]])
407
+ for (const { link, value } of linkValues(table, fileOwnerLinks, grant)) {
408
+ values.set(link.column, value)
409
+ }
410
+ values.set('status', spec.scan ? 'quarantined' : 'live')
411
+ values.set('object_key', file.objectKey)
412
+ values.set('size', file.size)
413
+ values.set('content_type', file.contentType)
414
+ values.set('sha256', file.sha256)
415
+ values.set('original_name', file.originalName)
416
+ values.set('created_by', grant.principal)
417
+ values.set('created_at', file.createdAt)
418
+ if (spec.scan) {
419
+ values.set('scan_after', new Date())
420
+ }
421
+ await lockOwner(transaction, grant)
422
+ if (maxPerOwner !== null && (await this.countHeld(capability, transaction)) >= maxPerOwner) {
423
+ return 'limit'
424
+ }
425
+ await transaction.run(insertNode(table, values, acknowledgement(table, 'id', undefined)), guard('InsertQueryNode', table, undefined))
426
+ return 'adopted'
427
+ },
428
+
429
+ async countHeld(capability: unknown, using?: Session): Promise<number> {
385
430
  const grant = grantOf(capability, owner.dimension, spec.actions.write)
386
431
  const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
387
432
  const count = SelectionNode.create(
388
433
  AliasNode.create(AggregateFunctionNode.create('count', [SelectAllNode.create()]), IdentifierNode.create('count')),
389
434
  )
390
435
  const conditions = allOf([isHeld(), isNull(table, 'deleted_at', false)])
391
- const [row] = await session().run<Readonly<Record<string, unknown>>>(
436
+ const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
392
437
  selectNode(table, [count], conditions, scope),
393
438
  guard('SelectQueryNode', table, scope),
394
439
  )
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { runFixedStatement, type Session, sessionOf } from './database'
8
8
  import { QueryRefusedError } from './errors'
9
- import { FORM_ADMISSIONS_TABLE, FORM_CHALLENGES_TABLE } from './migrations'
9
+ import { FORM_ADMISSIONS_TABLE, FORM_BYTES_TABLE, FORM_CHALLENGES_TABLE } from './migrations'
10
10
  import type { DataLayerRuntime } from './store'
11
11
 
12
12
  /** The bucket of `fabrika_form_admissions` an inbox append takes from, in the append's own transaction. */
@@ -26,6 +26,13 @@ export interface FormAdmissions {
26
26
  * recorded and has not expired, which makes the token's second use a replay.
27
27
  */
28
28
  recordChallenge(form: string, tokenDigest: string, retainMs: number): Promise<boolean>
29
+ /**
30
+ * Reserves `bytes` of `form`'s hourly byte budget `limit`, in the database's current hour; `false`, with nothing
31
+ * taken, when they no longer fit. The check and the counter are one statement.
32
+ */
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
36
  }
30
37
 
31
38
  const A = FORM_ADMISSIONS_TABLE
@@ -46,6 +53,21 @@ insert into ${A} (form, bucket, window_start, count) values ($1, $2, date_trunc(
46
53
  on conflict (form, bucket, window_start) do update set count = ${A}.count + 1 where ${A}.count < $3::int
47
54
  returning count`
48
55
 
56
+ const B = FORM_BYTES_TABLE
57
+
58
+ const SQL_RESERVE_BYTES = `with pruned as (
59
+ delete from ${B} where (form, window_start) in (
60
+ select form, window_start from ${B} where window_start < date_trunc('hour', now())
61
+ limit ${PRUNE_BATCH} for update skip locked
62
+ )
63
+ )
64
+ insert into ${B} (form, window_start, bytes) select $1, date_trunc('hour', now()), $2::bigint where $2::bigint <= $3::bigint
65
+ on conflict (form, window_start) do update set bytes = ${B}.bytes + excluded.bytes where ${B}.bytes + excluded.bytes <= $3::bigint
66
+ returning bytes`
67
+
68
+ const SQL_RELEASE_BYTES = `update ${B} set bytes = greatest(bytes - $2::bigint, 0)
69
+ where form = $1 and window_start = date_trunc('hour', now()) returning bytes`
70
+
49
71
  const SQL_RECORD_CHALLENGE = `with pruned as (
50
72
  delete from ${C} where (form, token_digest) in (
51
73
  select form, token_digest from ${C} where expires_at <= now() and (form, token_digest) <> ($1, $2)
@@ -78,5 +100,20 @@ export function formAdmissions(runtime: () => DataLayerRuntime): FormAdmissions
78
100
  const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [form, tokenDigest, positiveInteger(retainMs, 'a retention')])
79
101
  return rows.length > 0
80
102
  },
103
+
104
+ async reserveBytes(form: string, bytes: number, limit: number): Promise<boolean> {
105
+ const rows = await runFixedStatement(session(), SQL_RESERVE_BYTES, [
106
+ form,
107
+ positiveInteger(bytes, 'a reservation'),
108
+ positiveInteger(limit, 'a byte budget'),
109
+ ])
110
+ return rows.length > 0
111
+ },
112
+
113
+ async releaseBytes(form: string, bytes: number): Promise<void> {
114
+ if (bytes > 0) {
115
+ await runFixedStatement(session(), SQL_RELEASE_BYTES, [form, positiveInteger(bytes, 'a release')])
116
+ }
117
+ },
81
118
  })
82
119
  }
@@ -0,0 +1,226 @@
1
+ // The framework's side of `fabrika_form_files` (`@fabrika/db/migrations`): the files a form received with a submission,
2
+ // before anything owns them. A row is written `pending` before its object, so it is the intent to clean that object up;
3
+ // the submission's inbox append makes it `held` in the inbox row's own transaction (`holdFormFiles`), and the job that
4
+ // drains the inbox claims it in the transaction that writes its owner, where `@fabrika/app` moves it into the
5
+ // collection's own table. Nothing serves a row of this table. Each operation is one fixed statement on the database's
6
+ // clock, reached only through `frameworkTablesOf`.
7
+
8
+ import { type FixedParameter, runFixedStatement, type Session, sessionOf } from './database'
9
+ import { QueryRefusedError } from './errors'
10
+ import { FORM_FILES_TABLE } from './migrations'
11
+ import type { DataLayerRuntime } from './store'
12
+
13
+ const F = FORM_FILES_TABLE
14
+
15
+ /** A file a form is about to write: its row comes first, then the object under `objectKey`. */
16
+ export interface StagedFormFile {
17
+ readonly id: string
18
+ readonly form: string
19
+ readonly collection: string
20
+ readonly objectKey: string
21
+ readonly contentType: string
22
+ readonly originalName: string | null
23
+ }
24
+
25
+ /** What writing a staged file's object recorded: its size and the SHA-256 of its bytes. */
26
+ export interface WrittenFormFile {
27
+ readonly id: string
28
+ readonly size: number
29
+ readonly sha256: string
30
+ }
31
+
32
+ /** The files a submission holds once its inbox row commits, and how long they wait for their owner. */
33
+ export interface HeldFormFiles {
34
+ readonly files: readonly WrittenFormFile[]
35
+ readonly holdMs: number
36
+ }
37
+
38
+ /** A held file a claim took out of the table: what the collection's own row records of it. */
39
+ export interface ClaimedFormFile {
40
+ readonly id: string
41
+ readonly objectKey: string
42
+ readonly size: number
43
+ readonly sha256: string
44
+ readonly contentType: string
45
+ readonly originalName: string | null
46
+ readonly createdAt: Date
47
+ }
48
+
49
+ export interface RemovableFormFile {
50
+ readonly id: string
51
+ readonly objectKey: string
52
+ }
53
+
54
+ export interface FormFiles {
55
+ /** Inserts the `pending` row of a file whose object is written next: the intent to clean that object up. */
56
+ stage(file: StagedFormFile): Promise<void>
57
+ /** Deletes a `pending` row once its object is gone; a held row stays. */
58
+ abandon(id: string): Promise<void>
59
+ /**
60
+ * Takes the held file `id` of `collection` out of the table, in `using` (the transaction that writes its owner), and
61
+ * answers it; `null` when there is none: never received, of another collection, already claimed, or its hold ended.
62
+ */
63
+ claim(collection: string, id: string, using: Session): Promise<ClaimedFormFile | null>
64
+ /** At most `limit` rows whose objects may go: held past their hold, or pending for longer than `pendingGraceMs`. */
65
+ removable(limit: number, pendingGraceMs: number): Promise<readonly RemovableFormFile[]>
66
+ /** Deletes a removable row once its object is gone; a row that is held or recently pending is never deleted. */
67
+ purge(id: string, pendingGraceMs: number): Promise<void>
68
+ }
69
+
70
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
71
+
72
+ /**
73
+ * Removable: held for an hour past its hold, or pending for longer than the grace in parameter `$<grace>`. The hour
74
+ * keeps the sweep off a file a job's transaction, which started before the hold ended, may still claim.
75
+ */
76
+ const removable = (grace: number): string =>
77
+ `(status = 'pending' and created_at < now() - $${grace}::bigint * interval '1 millisecond')
78
+ or (status = 'held' and held_until <= now() - interval '1 hour')`
79
+
80
+ const SQL_STAGE = `insert into ${F} (id, form, collection, status, object_key, content_type, original_name, created_at)
81
+ values ($1::uuid, $2, $3, 'pending', $4, $5, $6, now()) returning id`
82
+
83
+ const SQL_ABANDON = `delete from ${F} where id = $1::uuid and status = 'pending' returning id`
84
+
85
+ /** Every file of a submission at once: ids, sizes and digests as comma-separated lists, in the same order. */
86
+ const SQL_HOLD = `update ${F} as f set status = 'held', size = v.size, sha256 = v.sha256,
87
+ held_until = now() + $4::bigint * interval '1 millisecond'
88
+ from unnest(string_to_array($1, ',')::uuid[], string_to_array($2, ',')::bigint[], string_to_array($3, ',')) as v (id, size, sha256)
89
+ where f.id = v.id and f.status = 'pending' returning f.id`
90
+
91
+ // The clock of the statement, not of the transaction: a job's transaction may have started before the hold ended.
92
+ const SQL_CLAIM = `delete from ${F} where id = $1::uuid and collection = $2 and status = 'held' and held_until > clock_timestamp()
93
+ returning id::text as id, object_key, size, sha256, content_type, original_name, created_at`
94
+
95
+ const SQL_REMOVABLE = `select id::text as id, object_key from ${F} where ${removable(1)} order by created_at limit $2::int`
96
+
97
+ const SQL_PURGE = `delete from ${F} where id = $1::uuid and (${removable(2)}) returning id`
98
+
99
+ function positive(value: number, what: string): number {
100
+ if (!Number.isSafeInteger(value) || value < 1) {
101
+ throw new QueryRefusedError('invalid_argument', `${what} must be a whole number of at least 1`)
102
+ }
103
+ return value
104
+ }
105
+
106
+ function nonNegative(value: number, what: string): number {
107
+ if (!Number.isSafeInteger(value) || value < 0) {
108
+ throw new QueryRefusedError('invalid_argument', `${what} must be a whole number`)
109
+ }
110
+ return value
111
+ }
112
+
113
+ function sha256(value: string): string {
114
+ if (!/^[0-9a-f]{64}$/.test(value)) {
115
+ throw new QueryRefusedError('invalid_argument', 'a form file is recorded with the hex SHA-256 of its bytes')
116
+ }
117
+ return value
118
+ }
119
+
120
+ function uuid(value: string): string {
121
+ if (!UUID.test(value)) {
122
+ throw new QueryRefusedError('invalid_argument', 'a form file is named by its UUID')
123
+ }
124
+ return value
125
+ }
126
+
127
+ function record(row: unknown): Readonly<Record<string, unknown>> {
128
+ if (typeof row !== 'object' || row === null) {
129
+ throw new QueryRefusedError('unexpected_row', `${F} answered a row that is not an object`)
130
+ }
131
+ return Object.fromEntries(Object.entries(row))
132
+ }
133
+
134
+ function text(row: Readonly<Record<string, unknown>>, column: string): string {
135
+ const value = row[column]
136
+ if (typeof value !== 'string') {
137
+ throw new QueryRefusedError('unexpected_row', `${F}.${column} came back as ${typeof value}`)
138
+ }
139
+ return value
140
+ }
141
+
142
+ /** BIGINT comes back as a string. */
143
+ function size(row: Readonly<Record<string, unknown>>): number {
144
+ const value = Number(row['size'])
145
+ if (!Number.isSafeInteger(value) || value < 0) {
146
+ throw new QueryRefusedError('unexpected_row', `${F}.size is not a non-negative integer`)
147
+ }
148
+ return value
149
+ }
150
+
151
+ /**
152
+ * Makes every file of a submission `held`, in `session`: the transaction that appends its inbox row, so a row that does
153
+ * not commit leaves its files pending for the form to clean up. A file that is no longer pending fails the transaction.
154
+ */
155
+ export async function holdFormFiles(session: Session, held: HeldFormFiles): Promise<void> {
156
+ if (held.files.length === 0) {
157
+ return
158
+ }
159
+ const parameters: FixedParameter[] = [
160
+ held.files.map((file) => uuid(file.id)).join(','),
161
+ held.files.map((file) => nonNegative(file.size, 'a form file size')).join(','),
162
+ held.files.map((file) => sha256(file.sha256)).join(','),
163
+ positive(held.holdMs, 'a form file hold'),
164
+ ]
165
+ const rows = await runFixedStatement(session, SQL_HOLD, parameters)
166
+ if (rows.length !== held.files.length) {
167
+ throw new QueryRefusedError('unexpected_row', 'a form file is no longer pending; the submission writes nothing')
168
+ }
169
+ }
170
+
171
+ export function formFiles(runtime: () => DataLayerRuntime): FormFiles {
172
+ const session = () => sessionOf(runtime())
173
+ return Object.freeze({
174
+ async stage(file: StagedFormFile): Promise<void> {
175
+ await runFixedStatement(session(), SQL_STAGE, [
176
+ uuid(file.id),
177
+ file.form,
178
+ file.collection,
179
+ file.objectKey,
180
+ file.contentType,
181
+ file.originalName,
182
+ ])
183
+ },
184
+
185
+ async abandon(id: string): Promise<void> {
186
+ await runFixedStatement(session(), SQL_ABANDON, [uuid(id)])
187
+ },
188
+
189
+ async claim(collection: string, id: string, using: Session): Promise<ClaimedFormFile | null> {
190
+ if (!UUID.test(id)) {
191
+ return null
192
+ }
193
+ const [found] = await runFixedStatement(using, SQL_CLAIM, [id, collection])
194
+ if (found === undefined) {
195
+ return null
196
+ }
197
+ const row = record(found)
198
+ const createdAt = row['created_at']
199
+ if (!(createdAt instanceof Date)) {
200
+ throw new QueryRefusedError('unexpected_row', `${F}.created_at came back as ${typeof createdAt}`)
201
+ }
202
+ const name = row['original_name']
203
+ return Object.freeze({
204
+ id: text(row, 'id'),
205
+ objectKey: text(row, 'object_key'),
206
+ size: size(row),
207
+ sha256: text(row, 'sha256'),
208
+ contentType: text(row, 'content_type'),
209
+ originalName: typeof name === 'string' ? name : null,
210
+ createdAt,
211
+ })
212
+ },
213
+
214
+ async removable(limit: number, pendingGraceMs: number): Promise<readonly RemovableFormFile[]> {
215
+ const rows = await runFixedStatement(session(), SQL_REMOVABLE, [positive(pendingGraceMs, 'a pending grace'), positive(limit, 'a batch')])
216
+ return rows.map((found) => {
217
+ const row = record(found)
218
+ return Object.freeze({ id: text(row, 'id'), objectKey: text(row, 'object_key') })
219
+ })
220
+ },
221
+
222
+ async purge(id: string, pendingGraceMs: number): Promise<void> {
223
+ await runFixedStatement(session(), SQL_PURGE, [uuid(id), positive(pendingGraceMs, 'a pending grace')])
224
+ },
225
+ })
226
+ }
package/src/framework.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  } from './errors'
21
21
  export { fileTableName } from './file-table-sql'
22
22
  export type {
23
+ AdoptedFile,
23
24
  FileCollectionSpec,
24
25
  FileCollectionTable,
25
26
  FilePage,
@@ -32,6 +33,7 @@ export type {
32
33
  StoredFile,
33
34
  } from './file-tables'
34
35
  export type { FormAdmissions, InboxQuota } from './form-admissions'
36
+ export type { ClaimedFormFile, FormFiles, HeldFormFiles, RemovableFormFile, StagedFormFile, WrittenFormFile } from './form-files'
35
37
  export type { NewWebhookBinding, WebhookBinding, WebhookTables } from './framework-tables'
36
38
  export type { JobLeases, JobLeaseState, JobOutcome, OverdueClaim } from './job-leases'
37
39
  export type { DataAccess, DataHandles, DataSite } from './orm'
package/src/migrations.ts CHANGED
@@ -11,6 +11,8 @@ export const JOB_LEASES_TABLE = 'fabrika_job_leases'
11
11
  export const JOB_KICKS_TABLE = 'fabrika_job_kicks'
12
12
  export const FORM_ADMISSIONS_TABLE = 'fabrika_form_admissions'
13
13
  export const FORM_CHALLENGES_TABLE = 'fabrika_form_challenges'
14
+ export const FORM_FILES_TABLE = 'fabrika_form_files'
15
+ export const FORM_BYTES_TABLE = 'fabrika_form_bytes'
14
16
 
15
17
  export interface FrameworkMigration {
16
18
  /** Ordered by name; an applied one is immutable, like an application's. */
@@ -92,6 +94,40 @@ CREATE TABLE ${JOB_KICKS_TABLE} (
92
94
  job TEXT PRIMARY KEY,
93
95
  pending BOOLEAN NOT NULL
94
96
  );
97
+ `,
98
+ }),
99
+ Object.freeze({
100
+ name: '0005_form_files.sql',
101
+ sql: `-- A file a form received with a submission, owned by nobody and served by no endpoint. It is 'pending' from before
102
+ -- its object (<collection>/<id>) is written until the submission's inbox row commits, then 'held' until held_until:
103
+ -- the job that drains the inbox attaches it to its owner, which moves it into the collection's own table, or the
104
+ -- sweep deletes its object and the row once the hold ended (a pending row once it is an hour old).
105
+ CREATE TABLE ${FORM_FILES_TABLE} (
106
+ id UUID PRIMARY KEY,
107
+ form TEXT NOT NULL,
108
+ collection TEXT NOT NULL,
109
+ status TEXT NOT NULL CONSTRAINT ${FORM_FILES_TABLE}_status CHECK (status IN ('pending', 'held')),
110
+ object_key TEXT NOT NULL UNIQUE,
111
+ size BIGINT,
112
+ sha256 TEXT,
113
+ content_type TEXT NOT NULL,
114
+ original_name TEXT,
115
+ created_at TIMESTAMPTZ NOT NULL,
116
+ held_until TIMESTAMPTZ
117
+ );
118
+ CREATE INDEX ${FORM_FILES_TABLE}_created_at ON ${FORM_FILES_TABLE} (created_at);
119
+ `,
120
+ }),
121
+ Object.freeze({
122
+ name: '0006_form_bytes.sql',
123
+ sql: `-- The bytes a form's files may write per hour, shared by every instance: one row per form and hour, which the next
124
+ -- reservation of a later hour prunes. A file reserves its collection's maxBytes before its object is written.
125
+ CREATE TABLE ${FORM_BYTES_TABLE} (
126
+ form TEXT NOT NULL,
127
+ window_start TIMESTAMPTZ NOT NULL,
128
+ bytes BIGINT NOT NULL,
129
+ PRIMARY KEY (form, window_start)
130
+ );
95
131
  `,
96
132
  }),
97
133
  ])
package/src/registry.ts CHANGED
@@ -2,15 +2,20 @@
2
2
  // `store.ts`, which registers and builds the framework's stores, `framework.ts`, which `@fabrika/app` alone imports, and `testing.ts`, which only test
3
3
  // files may import.
4
4
 
5
+ import type { Session } from './database'
5
6
  import type { FileTables } from './file-tables'
6
7
  import type { FormAdmissions, InboxQuota } from './form-admissions'
8
+ import type { FormFiles, HeldFormFiles } from './form-files'
7
9
  import type { WebhookTables } from './framework-tables'
8
10
  import type { JobLeases } from './job-leases'
9
11
  import type { DataAccess } from './orm'
10
12
  import type { DataLayerRuntime, TableContext, TableProbe } from './store'
11
13
 
12
- /** Answers what the framework's access check reads from the database: an object's containers, by dimension. */
13
- export type Locator = (dimension: string, id: string) => Promise<ReadonlyMap<string, string> | null>
14
+ /**
15
+ * Answers what the framework's access check reads from the database: an object's containers, by dimension. `using` is
16
+ * the session to read in (a job's transaction, which sees the object it just wrote); a statement of its own otherwise.
17
+ */
18
+ export type Locator = (dimension: string, id: string, using?: Session) => Promise<ReadonlyMap<string, string> | null>
14
19
 
15
20
  /** What the framework writes itself: a form's inbox row and its own `fabrika_*` tables. Usable once bound. */
16
21
  export interface FrameworkTables {
@@ -18,13 +23,16 @@ export interface FrameworkTables {
18
23
  inboxProblems(form: string, fields: readonly string[]): readonly string[]
19
24
  /**
20
25
  * Appends one row of `values` (column → value) to the inbox table of `form`, taking one from `quota` in the same
21
- * transaction; `false`, with nothing written, when the bucket is full.
26
+ * transaction, and makes the submission's `held` files held in it too; `false`, with nothing written, when the
27
+ * bucket is full.
22
28
  */
23
- appendToInbox(form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota): Promise<boolean>
29
+ appendToInbox(form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota, held?: HeldFormFiles): Promise<boolean>
24
30
  readonly webhooks: WebhookTables
25
31
  readonly jobs: JobLeases
26
32
  /** A form's admission counters and the challenge tokens it accepted. */
27
33
  readonly forms: FormAdmissions
34
+ /** The files forms received, until the job that drains each inbox attaches them to their owner. */
35
+ readonly formFiles: FormFiles
28
36
  }
29
37
 
30
38
  export interface FrameworkSide {
package/src/store.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import { CapabilityError, ConflictError, NotFoundError, PolicyError, QueryRefusedError } from './errors'
45
45
  import { fileTables } from './file-tables'
46
46
  import { formAdmissions, type InboxQuota, takeFromBucket } from './form-admissions'
47
+ import { formFiles, type HeldFormFiles, holdFormFiles } from './form-files'
47
48
  import { webhookTables } from './framework-tables'
48
49
  import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
49
50
  import { fencedTransaction, jobLeases } from './job-leases'
@@ -1551,16 +1552,17 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
1551
1552
  }
1552
1553
  const bound: DataLayerRuntime = freeze({ database: runtime.database, capabilities: runtime.capabilities, readOnly: runtime.readOnly })
1553
1554
  connection.runtime = bound
1554
- return async (dimension, id) => locateObject(models, bound, dimension, id)
1555
+ return async (dimension, id, using) => locateObject(models, bound, dimension, id, using)
1555
1556
  },
1556
1557
  files: fileTables(models, () => runtimeOf(connection)),
1557
1558
  framework: freeze({
1558
1559
  inboxProblems: (form: string, fields: readonly string[]) => inboxProblems(models, form, fields),
1559
- appendToInbox: (form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota) =>
1560
- appendToInbox(models, runtimeOf(connection), form, values, quota),
1560
+ appendToInbox: (form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota, held?: HeldFormFiles) =>
1561
+ appendToInbox(models, runtimeOf(connection), form, values, quota, held),
1561
1562
  webhooks: webhookTables(() => runtimeOf(connection)),
1562
1563
  jobs: jobLeases(() => runtimeOf(connection)),
1563
1564
  forms: formAdmissions(() => runtimeOf(connection)),
1565
+ formFiles: formFiles(() => runtimeOf(connection)),
1564
1566
  }),
1565
1567
  data: dataAccess({
1566
1568
  models,
@@ -1639,8 +1641,9 @@ function inboxProblems(models: TableModels, form: string, fields: readonly strin
1639
1641
 
1640
1642
  /**
1641
1643
  * One row of the form's declared fields, taken from the form's `quota` bucket in the same transaction: a row that
1642
- * fails to insert gives its place back. `false`, with nothing written, when the bucket is full. An inbox answers
1643
- * nothing, so the form reads nothing back.
1644
+ * fails to insert gives its place back. The submission's files become held in that transaction too, so a row that does
1645
+ * not commit holds none. `false`, with nothing written, when the bucket is full. An inbox answers nothing, so the form
1646
+ * reads nothing back.
1644
1647
  */
1645
1648
  async function appendToInbox(
1646
1649
  models: TableModels,
@@ -1648,6 +1651,7 @@ async function appendToInbox(
1648
1651
  form: string,
1649
1652
  values: Readonly<Record<string, unknown>>,
1650
1653
  quota: InboxQuota,
1654
+ held: HeldFormFiles | undefined,
1651
1655
  ): Promise<boolean> {
1652
1656
  const inbox = inboxOf(models, form)
1653
1657
  if (inbox === undefined) {
@@ -1664,6 +1668,9 @@ async function appendToInbox(
1664
1668
  joins: {},
1665
1669
  scope: undefined,
1666
1670
  })
1671
+ if (held !== undefined) {
1672
+ await holdFormFiles(transaction, held)
1673
+ }
1667
1674
  return true
1668
1675
  })
1669
1676
  }
@@ -1695,6 +1702,7 @@ async function locateObject(
1695
1702
  runtime: DataLayerRuntime,
1696
1703
  dimension: string,
1697
1704
  id: string,
1705
+ using: Session | undefined,
1698
1706
  ): Promise<ReadonlyMap<string, string> | null> {
1699
1707
  const owner = locatedOwner(models, dimension)
1700
1708
  if (typeof id !== 'string' || id.length === 0) {
@@ -1707,7 +1715,7 @@ async function locateObject(
1707
1715
  columns[columns.length] = owner.chain[index]?.column ?? ''
1708
1716
  }
1709
1717
  const context: GuardContext = { root: 'SelectQueryNode', table: owner.name, joins: {}, scope }
1710
- const [row] = await sessionOf(runtime).run<Readonly<Record<string, unknown>>>(locateNode(owner.name, columns, scope), context)
1718
+ const [row] = await (using ?? sessionOf(runtime)).run<Readonly<Record<string, unknown>>>(locateNode(owner.name, columns, scope), context)
1711
1719
  if (row === undefined) {
1712
1720
  return null
1713
1721
  }