@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.
- package/package.json +3 -3
- package/src/{form-admissions.ts → admissions.ts} +29 -36
- package/src/capability.ts +30 -3
- package/src/description.ts +2 -1
- package/src/errors.ts +8 -2
- package/src/file-columns.ts +184 -0
- package/src/file-references.ts +60 -0
- package/src/framework-tables.ts +100 -45
- package/src/framework.ts +7 -29
- package/src/guard.ts +42 -8
- package/src/index.ts +2 -13
- package/src/job-leases.ts +11 -0
- package/src/links.ts +1 -1
- package/src/migrations.ts +126 -0
- package/src/nodes.ts +35 -3
- package/src/orm-types.ts +152 -28
- package/src/orm.ts +397 -38
- package/src/policy.ts +221 -74
- package/src/registry.ts +7 -18
- package/src/store.ts +447 -143
- package/src/stored-files.ts +402 -0
- package/src/tables.ts +32 -26
- package/src/testing.ts +39 -7
- package/src/vocabulary-tables.ts +4 -4
- package/src/file-table-sql.ts +0 -133
- package/src/file-tables.ts +0 -622
- package/src/form-files.ts +0 -226
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.
|
|
65
|
-
"@fabrika/platform-node": "0.
|
|
64
|
+
"@fabrika/auth-core": "0.7.0",
|
|
65
|
+
"@fabrika/platform-node": "0.7.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 `
|
|
2
|
-
// per-minute
|
|
3
|
-
// the challenge tokens
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 `
|
|
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(
|
|
19
|
+
take(endpoint: string, bucket: string, limit: number): Promise<boolean>
|
|
24
20
|
/**
|
|
25
|
-
* Records the digest of a challenge token `
|
|
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(
|
|
24
|
+
recordChallenge(endpoint: string, tokenDigest: string, retainMs: number): Promise<boolean>
|
|
29
25
|
/**
|
|
30
|
-
* Reserves `bytes` of `
|
|
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(
|
|
34
|
-
/** Gives back `bytes` of a reservation of the current hour that
|
|
35
|
-
releaseBytes(
|
|
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
|
-
|
|
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
|
|
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(
|
|
100
|
-
const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [
|
|
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(
|
|
97
|
+
async reserveBytes(endpoint: string, bytes: number, limit: number): Promise<boolean> {
|
|
105
98
|
const rows = await runFixedStatement(session(), SQL_RESERVE_BYTES, [
|
|
106
|
-
|
|
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(
|
|
106
|
+
async releaseBytes(endpoint: string, bytes: number): Promise<void> {
|
|
114
107
|
if (bytes > 0) {
|
|
115
|
-
await runFixedStatement(session(), SQL_RELEASE_BYTES, [
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/description.ts
CHANGED
|
@@ -63,7 +63,8 @@ export interface TableShape {
|
|
|
63
63
|
|
|
64
64
|
export interface JoinShape {
|
|
65
65
|
readonly shape: TableShape
|
|
66
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|