@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 +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/src/form-files.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
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
|
-
}
|