@fabrika/db 0.0.0-stage → 0.4.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 +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
// The framework's side of a file collection's table (`fabrika_files_<collection>`): the rows `@fabrika/app`'s files
|
|
2
|
+
// runtime writes and reads. No store reaches these tables, and codegen leaves them out. Every operation a request
|
|
3
|
+
// makes takes the request's capability and carries its whole containment chain as the scope predicate, exactly as a
|
|
4
|
+
// store's does, so a capability minted before its owner moved matches nothing.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AggregateFunctionNode,
|
|
8
|
+
AliasNode,
|
|
9
|
+
BinaryOperationNode,
|
|
10
|
+
FromNode,
|
|
11
|
+
IdentifierNode,
|
|
12
|
+
LimitNode,
|
|
13
|
+
type OperationNode,
|
|
14
|
+
OperatorNode,
|
|
15
|
+
OrderByItemNode,
|
|
16
|
+
OrderByNode,
|
|
17
|
+
ParensNode,
|
|
18
|
+
RawNode,
|
|
19
|
+
SelectAllNode,
|
|
20
|
+
SelectionNode,
|
|
21
|
+
type SelectQueryNode,
|
|
22
|
+
TableNode,
|
|
23
|
+
ValueNode,
|
|
24
|
+
} from 'kysely'
|
|
25
|
+
import { freeze, ownEntry } from './builtins'
|
|
26
|
+
import { describeGrant, type RefGrant } from './capability'
|
|
27
|
+
import { type Session, sessionOf } from './database'
|
|
28
|
+
import { CapabilityError, NotFoundError, PolicyError, QueryRefusedError } from './errors'
|
|
29
|
+
import { FILE_COLUMNS, type FileStatus, tableOf } from './file-table-sql'
|
|
30
|
+
import type { GuardContext } from './guard'
|
|
31
|
+
import { fencedTransaction } from './job-leases'
|
|
32
|
+
import { linksPredicate, linkValues } from './links'
|
|
33
|
+
import { acknowledgement, allOf, anyOf, column, deleteNode, equals, everything, insertNode, lockNode, scopedWhere, updateNode } from './nodes'
|
|
34
|
+
import { type Container, modelOf, type TableModel, type TableModels } from './policy'
|
|
35
|
+
import type { DataLayerRuntime } from './store'
|
|
36
|
+
|
|
37
|
+
/** What `@fabrika/app` declares of one collection: its owner table, its dimension and its three actions. */
|
|
38
|
+
export interface FileCollectionSpec {
|
|
39
|
+
readonly name: string
|
|
40
|
+
readonly owner: string
|
|
41
|
+
/** `file:<name>`, contained in the owner's dimension. */
|
|
42
|
+
readonly dimension: string
|
|
43
|
+
readonly actions: { readonly read: string; readonly write: string; readonly delete: string }
|
|
44
|
+
/** Whether the collection scans its uploads: only then does its table carry the scan columns. */
|
|
45
|
+
readonly scan: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PendingFile {
|
|
49
|
+
readonly id: string
|
|
50
|
+
readonly objectKey: string
|
|
51
|
+
readonly contentType: string
|
|
52
|
+
readonly originalName: string | null
|
|
53
|
+
readonly createdAt: Date
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file whose upload completed: served when `live`, `409` while `quarantined`, gone when `rejected`. */
|
|
57
|
+
export type HeldStatus = Exclude<FileStatus, 'pending'>
|
|
58
|
+
|
|
59
|
+
export interface StoredFile {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly objectKey: string
|
|
62
|
+
readonly size: number
|
|
63
|
+
readonly contentType: string
|
|
64
|
+
readonly originalName: string | null
|
|
65
|
+
readonly status: HeldStatus
|
|
66
|
+
/** The object version a scanning collection's file was scanned as; `null` before a verdict or without scanning. */
|
|
67
|
+
readonly objectVersion: string | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One row of an owner's file list. */
|
|
71
|
+
export interface ListedFile {
|
|
72
|
+
readonly id: string
|
|
73
|
+
readonly name: string | null
|
|
74
|
+
readonly type: string
|
|
75
|
+
readonly size: number
|
|
76
|
+
readonly status: HeldStatus
|
|
77
|
+
readonly createdAt: Date
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A keyset page of an owner's files, oldest first; `next` is the `after` of the following page. */
|
|
81
|
+
export interface FilePage {
|
|
82
|
+
readonly items: readonly ListedFile[]
|
|
83
|
+
readonly next: string | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the scan pass needs of a quarantined file: its object, the size and digest its upload recorded, its tries. */
|
|
87
|
+
export interface QuarantinedFile {
|
|
88
|
+
readonly id: string
|
|
89
|
+
readonly objectKey: string
|
|
90
|
+
readonly size: number
|
|
91
|
+
readonly sha256: string
|
|
92
|
+
readonly contentType: string
|
|
93
|
+
/** Earlier passes that gave it no verdict, or whose rejection could not delete its object. */
|
|
94
|
+
readonly scanAttempts: number
|
|
95
|
+
/** Since when it waits: its quarantine, or the end of its last retry's wait. Oldest first, across collections. */
|
|
96
|
+
readonly dueSince: Date
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A row whose object may be deleted: removed, orphaned by its owner's deletion, or pending past the grace period. */
|
|
100
|
+
export interface RemovableFile {
|
|
101
|
+
readonly id: string
|
|
102
|
+
readonly objectKey: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FileCollectionTable {
|
|
106
|
+
/** Locks the owner where `owner` says it lies and inserts a `pending` row there: the intent to clean its object up. */
|
|
107
|
+
begin(owner: unknown, file: PendingFile): Promise<void>
|
|
108
|
+
/**
|
|
109
|
+
* Locks the owner again and ends the pending row in `status`: `live`, or `quarantined` in a scanning collection;
|
|
110
|
+
* `NotFoundError` when the owner moved or the row is gone.
|
|
111
|
+
*/
|
|
112
|
+
complete(owner: unknown, id: string, written: { readonly size: number; readonly sha256: string }, status: 'live' | 'quarantined'): Promise<void>
|
|
113
|
+
/** Deletes the caller's own pending row once its object is gone. */
|
|
114
|
+
abandon(id: string): Promise<void>
|
|
115
|
+
/** The owner's files that hold a place in its quota: live, quarantined and rejected, until removed. */
|
|
116
|
+
countHeld(owner: unknown): Promise<number>
|
|
117
|
+
/** Marks a held file of `owner` removed. */
|
|
118
|
+
retract(owner: unknown, id: string, at: Date): Promise<void>
|
|
119
|
+
/**
|
|
120
|
+
* The held file `file` names, over its whole chain, in any held state; `NotFoundError` otherwise. `using` is the
|
|
121
|
+
* session of the site that reads it (a handler's invocation, a job's transaction); the request's own by default.
|
|
122
|
+
*/
|
|
123
|
+
find(file: unknown, using?: Session): Promise<StoredFile>
|
|
124
|
+
/** Marks the held file `file` names removed, in any held state. */
|
|
125
|
+
markRemoved(file: unknown, at: Date): Promise<void>
|
|
126
|
+
/**
|
|
127
|
+
* Marks every file of the owner `owner` (a `Ref` for the delete action) removed, in `using`, held files and pending
|
|
128
|
+
* uploads alike: an upload begun before the removal then fails its completion and cleans up. Answers how many.
|
|
129
|
+
*/
|
|
130
|
+
removeOwned(owner: unknown, at: Date, using: Session): Promise<number>
|
|
131
|
+
/** The containers of a held file, read from its row: its owner and the owner's chain. */
|
|
132
|
+
locate(id: string, using?: Session): Promise<ReadonlyMap<string, string> | null>
|
|
133
|
+
/** At most `limit` held files of the owner `owner` (a `Ref` for the read action) after the id `after`, oldest first. */
|
|
134
|
+
list(owner: unknown, page: { readonly after: string | null; readonly limit: number }, using?: Session): Promise<FilePage>
|
|
135
|
+
/**
|
|
136
|
+
* At most `limit` quarantined files that are neither removed nor orphaned and due at `now`, the longest waiting
|
|
137
|
+
* first, so neither files that keep failing nor a busy collection hold back the rest.
|
|
138
|
+
*/
|
|
139
|
+
quarantined(limit: number, now: Date): Promise<readonly QuarantinedFile[]>
|
|
140
|
+
/**
|
|
141
|
+
* Makes a quarantined file `live` when it is still quarantined, not removed, not orphaned and its recorded digest is
|
|
142
|
+
* `sha256`, and records `version`, the scanned object's. Only inside a job attempt's lease, which fences the write;
|
|
143
|
+
* `false` when no row matched.
|
|
144
|
+
*/
|
|
145
|
+
promote(id: string, sha256: string, version: string): Promise<boolean>
|
|
146
|
+
/** Records a try that gave no verdict, the `attempts`-th, and when the next may start; fenced like `promote`. */
|
|
147
|
+
deferScan(id: string, attempts: number, after: Date): Promise<boolean>
|
|
148
|
+
/**
|
|
149
|
+
* Quarantines a live file of a scanning collection again, when its object is no longer the version it was scanned
|
|
150
|
+
* as (`version`, `null` for a file that went live before the collection scanned): the download that saw it.
|
|
151
|
+
*/
|
|
152
|
+
requarantine(file: unknown, version: string | null): Promise<void>
|
|
153
|
+
/** Marks a quarantined file `rejected` once its object is deleted; fenced like `promote`. `false` when no row matched. */
|
|
154
|
+
reject(id: string): Promise<boolean>
|
|
155
|
+
/**
|
|
156
|
+
* At most `limit` rows whose objects may be deleted: a removed or orphaned file in any held state, and a pending row
|
|
157
|
+
* only when created before `pendingBefore`.
|
|
158
|
+
*/
|
|
159
|
+
removable(limit: number, pendingBefore: Date): Promise<readonly RemovableFile[]>
|
|
160
|
+
/** Deletes the row of a removable file; a held file's row, and a recent pending one, are never deleted. */
|
|
161
|
+
purge(id: string, pendingBefore: Date): Promise<void>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FileTables {
|
|
165
|
+
/** The table of one collection; throws `PolicyError` when its owner is not a dimension table it can carry. */
|
|
166
|
+
collection(spec: FileCollectionSpec): FileCollectionTable
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface Owner {
|
|
170
|
+
readonly model: TableModel
|
|
171
|
+
readonly dimension: string
|
|
172
|
+
readonly key: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function ownerOf(models: TableModels, spec: FileCollectionSpec): Owner {
|
|
176
|
+
const model = modelOf(models, spec.owner)
|
|
177
|
+
if (model === undefined || model.scope.mode !== 'dimension') {
|
|
178
|
+
throw new PolicyError(`files '${spec.name}': its owner ${spec.owner} is not a dimension table`)
|
|
179
|
+
}
|
|
180
|
+
for (const link of model.chain) {
|
|
181
|
+
if (FILE_COLUMNS.includes(link.column)) {
|
|
182
|
+
throw new PolicyError(`files '${spec.name}': the owner column ${link.column} has the name of a file column`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { model, dimension: model.scope.dimension, key: model.scope.key }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isNull(table: string, name: string, negated: boolean): OperationNode {
|
|
189
|
+
return BinaryOperationNode.create(column(table, name), OperatorNode.create(negated ? 'is not' : 'is'), ValueNode.createImmediate(null))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compare(table: string, name: string, operator: '=' | '<' | '>', value: string | number | Date): OperationNode {
|
|
193
|
+
return BinaryOperationNode.create(column(table, name), OperatorNode.create(operator), ValueNode.create(value))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function selectNode(
|
|
197
|
+
table: string,
|
|
198
|
+
selections: readonly SelectionNode[],
|
|
199
|
+
conditions: OperationNode,
|
|
200
|
+
scope: OperationNode,
|
|
201
|
+
limit?: number,
|
|
202
|
+
orderBy: readonly string[] = [],
|
|
203
|
+
): SelectQueryNode {
|
|
204
|
+
const node: SelectQueryNode = {
|
|
205
|
+
kind: 'SelectQueryNode',
|
|
206
|
+
from: FromNode.create([TableNode.create(table)]),
|
|
207
|
+
selections,
|
|
208
|
+
where: scopedWhere(conditions, scope),
|
|
209
|
+
orderBy: orderBy.length === 0
|
|
210
|
+
? undefined
|
|
211
|
+
: OrderByNode.create(orderBy.map((name) => OrderByItemNode.create(column(table, name), RawNode.createWithSql('asc')))),
|
|
212
|
+
limit: limit === undefined ? undefined : LimitNode.create(ValueNode.create(limit)),
|
|
213
|
+
}
|
|
214
|
+
return freeze(node)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function selections(table: string, names: readonly string[]): SelectionNode[] {
|
|
218
|
+
return names.map((name) => SelectionNode.create(column(table, name)))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function guard(root: GuardContext['root'], table: string, scope: OperationNode | undefined, lock?: 'share'): GuardContext {
|
|
222
|
+
return lock === undefined ? { root, table, joins: {}, scope } : { root, table, joins: {}, scope, lock }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function text(table: string, row: Readonly<Record<string, unknown>>, name: string): string {
|
|
226
|
+
const value = ownEntry(row, name)
|
|
227
|
+
if (typeof value !== 'string') {
|
|
228
|
+
throw new QueryRefusedError('unexpected_row', `${table}.${name} came back as ${typeof value}`)
|
|
229
|
+
}
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** BIGINT and `count` come back as strings. */
|
|
234
|
+
function integer(table: string, row: Readonly<Record<string, unknown>>, name: string): number {
|
|
235
|
+
const value = Number(ownEntry(row, name))
|
|
236
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
237
|
+
throw new QueryRefusedError('unexpected_row', `${table}.${name} is not a non-negative integer`)
|
|
238
|
+
}
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const STORED_COLUMNS = ['id', 'object_key', 'size', 'content_type', 'original_name', 'status']
|
|
243
|
+
const SCANNED_COLUMNS = [...STORED_COLUMNS, 'object_version']
|
|
244
|
+
const LISTED_COLUMNS = ['id', 'original_name', 'content_type', 'size', 'status', 'created_at']
|
|
245
|
+
const HELD: readonly HeldStatus[] = ['live', 'quarantined', 'rejected']
|
|
246
|
+
|
|
247
|
+
function heldStatus(table: string, row: Readonly<Record<string, unknown>>): HeldStatus {
|
|
248
|
+
const status = ownEntry(row, 'status')
|
|
249
|
+
const held = HELD.find((candidate) => candidate === status)
|
|
250
|
+
if (held === undefined) {
|
|
251
|
+
throw new QueryRefusedError('unexpected_row', `${table}.status came back as a state no held file has`)
|
|
252
|
+
}
|
|
253
|
+
return held
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function nullableText(row: Readonly<Record<string, unknown>>, name: string): string | null {
|
|
257
|
+
const value = ownEntry(row, name)
|
|
258
|
+
return typeof value === 'string' ? value : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function date(table: string, row: Readonly<Record<string, unknown>>, name: string): Date {
|
|
262
|
+
const value = ownEntry(row, name)
|
|
263
|
+
if (!(value instanceof Date)) {
|
|
264
|
+
throw new QueryRefusedError('unexpected_row', `${table}.${name} came back as ${typeof value}`)
|
|
265
|
+
}
|
|
266
|
+
return value
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function collectionTable(models: TableModels, runtime: () => DataLayerRuntime, spec: FileCollectionSpec): FileCollectionTable {
|
|
270
|
+
const table = tableOf(spec.name)
|
|
271
|
+
const owner = ownerOf(models, spec)
|
|
272
|
+
/** The owner row itself: its key, then its chain. */
|
|
273
|
+
const ownerLinks: readonly Container[] = [{ dimension: owner.dimension, column: owner.key }, ...owner.model.chain]
|
|
274
|
+
/** A file row's columns for its owner: `owner_id`, then the owner's chain under the owner's column names. */
|
|
275
|
+
const fileOwnerLinks: readonly Container[] = [{ dimension: owner.dimension, column: 'owner_id' }, ...owner.model.chain]
|
|
276
|
+
const fileLinks: readonly Container[] = [{ dimension: spec.dimension, column: 'id' }, ...fileOwnerLinks]
|
|
277
|
+
const session = (): Session => sessionOf(runtime())
|
|
278
|
+
|
|
279
|
+
function grantOf(capability: unknown, dimension: string, action: string): RefGrant {
|
|
280
|
+
const grant = runtime().capabilities.resolve(capability)
|
|
281
|
+
if (grant.kind !== 'ref' || grant.dimension !== dimension || grant.action !== action) {
|
|
282
|
+
throw new CapabilityError(
|
|
283
|
+
'wrong_capability',
|
|
284
|
+
`files '${spec.name}' needs a Ref of ${dimension} for ${action}, got ${describeGrant(grant)} for ${grant.action}`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return grant
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const isHeld = (): OperationNode => anyOf(HELD.map((status) => compare(table, 'status', '=', status)))
|
|
291
|
+
const status = (value: HeldStatus): OperationNode => compare(table, 'status', '=', value)
|
|
292
|
+
/** A held file that is neither removed nor orphaned: one a request may reach. */
|
|
293
|
+
const held = (state: OperationNode = isHeld()): OperationNode => allOf([state, isNull(table, 'deleted_at', false), isNull(table, 'owner_id', true)])
|
|
294
|
+
|
|
295
|
+
/** Removed or orphaned files in every held state, and pending rows older than `pendingBefore`; never an upload in progress. */
|
|
296
|
+
const removable = (pendingBefore: Date): OperationNode =>
|
|
297
|
+
anyOf([
|
|
298
|
+
ParensNode.create(allOf([isHeld(), anyOf([isNull(table, 'deleted_at', true), isNull(table, 'owner_id', false)])])),
|
|
299
|
+
ParensNode.create(allOf([compare(table, 'status', '=', 'pending'), compare(table, 'created_at', '<', pendingBefore)])),
|
|
300
|
+
])
|
|
301
|
+
|
|
302
|
+
/** A verdict of the scan pass: one conditional update, fenced by the attempt's lease. */
|
|
303
|
+
function scanning(operation: string): void {
|
|
304
|
+
if (!spec.scan) {
|
|
305
|
+
throw new QueryRefusedError('file_scan', `files '${spec.name}' does not scan; it has no ${operation}`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function verdict(id: string, changes: ReadonlyMap<string, string | number | Date | null>, conditions: OperationNode): Promise<boolean> {
|
|
310
|
+
const current = runtime()
|
|
311
|
+
const fence = current.capabilities.fence()
|
|
312
|
+
if (fence === null) {
|
|
313
|
+
throw new QueryRefusedError('file_scan', `files '${spec.name}': a scan verdict is written only inside the scan job's lease`)
|
|
314
|
+
}
|
|
315
|
+
const scope = equals(table, 'id', id)
|
|
316
|
+
const node = updateNode(table, changes, undefined, scopedWhere(conditions, scope), acknowledgement(table, 'id', undefined))
|
|
317
|
+
const updated = await fencedTransaction(sessionOf(current), fence, (transaction) => transaction.run(node, guard('UpdateQueryNode', table, scope)))
|
|
318
|
+
return updated.length > 0
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Locks the owner row `for share` where the Ref says it lies, in `transaction`; `NotFoundError` when it does not. */
|
|
322
|
+
async function lockOwner(transaction: Session, grant: RefGrant): Promise<void> {
|
|
323
|
+
const name = owner.model.name
|
|
324
|
+
const scope = linksPredicate(name, linkValues(name, ownerLinks, grant))
|
|
325
|
+
const rows = await transaction.run(lockNode(name, owner.key, scope), guard('SelectQueryNode', name, scope, 'share'))
|
|
326
|
+
if (rows.length === 0) {
|
|
327
|
+
throw new NotFoundError(`${name} ${grant.id} is not where its Ref says; obtain a fresh Ref`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ownedFile(grant: RefGrant, id: string): OperationNode {
|
|
332
|
+
return allOf([equals(table, 'id', id), linksPredicate(table, linkValues(table, fileOwnerLinks, grant))])
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return freeze({
|
|
336
|
+
async begin(capability: unknown, file: PendingFile): Promise<void> {
|
|
337
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
338
|
+
const values = new Map<string, string | Date | null>([['id', file.id]])
|
|
339
|
+
for (const { link, value } of linkValues(table, fileOwnerLinks, grant)) {
|
|
340
|
+
values.set(link.column, value)
|
|
341
|
+
}
|
|
342
|
+
values.set('status', 'pending')
|
|
343
|
+
values.set('object_key', file.objectKey)
|
|
344
|
+
values.set('content_type', file.contentType)
|
|
345
|
+
values.set('original_name', file.originalName)
|
|
346
|
+
values.set('created_by', grant.principal)
|
|
347
|
+
values.set('created_at', file.createdAt)
|
|
348
|
+
await session().transaction(async (transaction) => {
|
|
349
|
+
await lockOwner(transaction, grant)
|
|
350
|
+
await transaction.run(insertNode(table, values, acknowledgement(table, 'id', undefined)), guard('InsertQueryNode', table, undefined))
|
|
351
|
+
})
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async complete(
|
|
355
|
+
capability: unknown,
|
|
356
|
+
id: string,
|
|
357
|
+
written: { readonly size: number; readonly sha256: string },
|
|
358
|
+
ended: 'live' | 'quarantined',
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
361
|
+
const scope = ownedFile(grant, id)
|
|
362
|
+
const pending = allOf([compare(table, 'status', '=', 'pending'), isNull(table, 'deleted_at', false)])
|
|
363
|
+
const changes = new Map<string, string | number | Date>([['status', ended], ['size', written.size], ['sha256', written.sha256]])
|
|
364
|
+
if (ended === 'quarantined') {
|
|
365
|
+
scanning('quarantine')
|
|
366
|
+
changes.set('scan_after', new Date())
|
|
367
|
+
}
|
|
368
|
+
const node = updateNode(table, changes, undefined, scopedWhere(pending, scope), acknowledgement(table, 'id', undefined))
|
|
369
|
+
await session().transaction(async (transaction) => {
|
|
370
|
+
await lockOwner(transaction, grant)
|
|
371
|
+
const updated = await transaction.run(node, guard('UpdateQueryNode', table, scope))
|
|
372
|
+
if (updated.length === 0) {
|
|
373
|
+
throw new NotFoundError(`the pending file ${id} is no longer where its owner's Ref says`)
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async abandon(id: string): Promise<void> {
|
|
379
|
+
const scope = equals(table, 'id', id)
|
|
380
|
+
const where = scopedWhere(compare(table, 'status', '=', 'pending'), scope)
|
|
381
|
+
await session().run(deleteNode(table, where, 'id'), guard('DeleteQueryNode', table, scope))
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
async countHeld(capability: unknown): Promise<number> {
|
|
385
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
386
|
+
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
387
|
+
const count = SelectionNode.create(
|
|
388
|
+
AliasNode.create(AggregateFunctionNode.create('count', [SelectAllNode.create()]), IdentifierNode.create('count')),
|
|
389
|
+
)
|
|
390
|
+
const conditions = allOf([isHeld(), isNull(table, 'deleted_at', false)])
|
|
391
|
+
const [row] = await session().run<Readonly<Record<string, unknown>>>(
|
|
392
|
+
selectNode(table, [count], conditions, scope),
|
|
393
|
+
guard('SelectQueryNode', table, scope),
|
|
394
|
+
)
|
|
395
|
+
return row === undefined ? 0 : integer(table, row, 'count')
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
async retract(capability: unknown, id: string, at: Date): Promise<void> {
|
|
399
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
400
|
+
const scope = ownedFile(grant, id)
|
|
401
|
+
const node = updateNode(
|
|
402
|
+
table,
|
|
403
|
+
new Map([['deleted_at', at]]),
|
|
404
|
+
undefined,
|
|
405
|
+
scopedWhere(held(), scope),
|
|
406
|
+
acknowledgement(table, 'id', undefined),
|
|
407
|
+
)
|
|
408
|
+
await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async find(capability: unknown, using?: Session): Promise<StoredFile> {
|
|
412
|
+
const grant = grantOf(capability, spec.dimension, spec.actions.read)
|
|
413
|
+
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
414
|
+
const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
415
|
+
selectNode(table, selections(table, spec.scan ? SCANNED_COLUMNS : STORED_COLUMNS), held(), scope, 1),
|
|
416
|
+
guard('SelectQueryNode', table, scope),
|
|
417
|
+
)
|
|
418
|
+
if (row === undefined) {
|
|
419
|
+
throw new NotFoundError(`files '${spec.name}': the file is not where its Ref says`)
|
|
420
|
+
}
|
|
421
|
+
return freeze({
|
|
422
|
+
id: text(table, row, 'id'),
|
|
423
|
+
objectKey: text(table, row, 'object_key'),
|
|
424
|
+
size: integer(table, row, 'size'),
|
|
425
|
+
contentType: text(table, row, 'content_type'),
|
|
426
|
+
originalName: nullableText(row, 'original_name'),
|
|
427
|
+
status: heldStatus(table, row),
|
|
428
|
+
objectVersion: spec.scan ? nullableText(row, 'object_version') : null,
|
|
429
|
+
})
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async markRemoved(capability: unknown, at: Date): Promise<void> {
|
|
433
|
+
const grant = grantOf(capability, spec.dimension, spec.actions.delete)
|
|
434
|
+
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
435
|
+
const node = updateNode(
|
|
436
|
+
table,
|
|
437
|
+
new Map([['deleted_at', at]]),
|
|
438
|
+
undefined,
|
|
439
|
+
scopedWhere(held(), scope),
|
|
440
|
+
acknowledgement(table, 'id', undefined),
|
|
441
|
+
)
|
|
442
|
+
const updated = await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
443
|
+
if (updated.length === 0) {
|
|
444
|
+
throw new NotFoundError(`files '${spec.name}': the file is not where its Ref says`)
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
async removeOwned(capability: unknown, at: Date, using: Session): Promise<number> {
|
|
449
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.delete)
|
|
450
|
+
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
451
|
+
const node = updateNode(
|
|
452
|
+
table,
|
|
453
|
+
new Map([['deleted_at', at]]),
|
|
454
|
+
undefined,
|
|
455
|
+
scopedWhere(held(anyOf([isHeld(), compare(table, 'status', '=', 'pending')])), scope),
|
|
456
|
+
acknowledgement(table, 'id', undefined),
|
|
457
|
+
)
|
|
458
|
+
const removed = await using.run(node, guard('UpdateQueryNode', table, scope))
|
|
459
|
+
return removed.length
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
async locate(id: string, using?: Session): Promise<ReadonlyMap<string, string> | null> {
|
|
463
|
+
const scope = equals(table, 'id', id)
|
|
464
|
+
const columns = fileOwnerLinks.map((link) => link.column)
|
|
465
|
+
const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
466
|
+
selectNode(table, selections(table, columns), held(), scope, 1),
|
|
467
|
+
guard('SelectQueryNode', table, scope),
|
|
468
|
+
)
|
|
469
|
+
if (row === undefined) {
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
return new Map(fileOwnerLinks.map((link) => [link.dimension, text(table, row, link.column)]))
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
async list(capability: unknown, page: { readonly after: string | null; readonly limit: number }, using?: Session): Promise<FilePage> {
|
|
476
|
+
const grant = grantOf(capability, owner.dimension, spec.actions.read)
|
|
477
|
+
if (!Number.isSafeInteger(page.limit) || page.limit < 1) {
|
|
478
|
+
throw new QueryRefusedError('list_page', `files '${spec.name}': a page holds at least one file`)
|
|
479
|
+
}
|
|
480
|
+
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
481
|
+
const conditions = page.after === null ? held() : allOf([held(), compare(table, 'id', '>', page.after)])
|
|
482
|
+
const rows = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
483
|
+
selectNode(table, selections(table, LISTED_COLUMNS), conditions, scope, page.limit + 1, ['id']),
|
|
484
|
+
guard('SelectQueryNode', table, scope),
|
|
485
|
+
)
|
|
486
|
+
const items = rows.slice(0, page.limit).map((row) =>
|
|
487
|
+
freeze({
|
|
488
|
+
id: text(table, row, 'id'),
|
|
489
|
+
name: nullableText(row, 'original_name'),
|
|
490
|
+
type: text(table, row, 'content_type'),
|
|
491
|
+
size: integer(table, row, 'size'),
|
|
492
|
+
status: heldStatus(table, row),
|
|
493
|
+
createdAt: date(table, row, 'created_at'),
|
|
494
|
+
})
|
|
495
|
+
)
|
|
496
|
+
const last = items[items.length - 1]
|
|
497
|
+
return freeze({ items, next: rows.length > page.limit && last !== undefined ? last.id : null })
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
async quarantined(limit: number, now: Date): Promise<readonly QuarantinedFile[]> {
|
|
501
|
+
scanning('scan batch')
|
|
502
|
+
const scope = everything()
|
|
503
|
+
const due = allOf([held(status('quarantined')), anyOf([isNull(table, 'scan_after', false), compare(table, 'scan_after', '<', now)])])
|
|
504
|
+
const columns = ['id', 'object_key', 'size', 'sha256', 'content_type', 'scan_attempts', 'scan_after', 'created_at']
|
|
505
|
+
const rows = await session().run<Readonly<Record<string, unknown>>>(
|
|
506
|
+
selectNode(table, selections(table, columns), due, scope, limit, ['scan_after', 'created_at']),
|
|
507
|
+
guard('SelectQueryNode', table, scope),
|
|
508
|
+
)
|
|
509
|
+
return rows.map((row) =>
|
|
510
|
+
freeze({
|
|
511
|
+
id: text(table, row, 'id'),
|
|
512
|
+
objectKey: text(table, row, 'object_key'),
|
|
513
|
+
size: integer(table, row, 'size'),
|
|
514
|
+
sha256: text(table, row, 'sha256'),
|
|
515
|
+
contentType: text(table, row, 'content_type'),
|
|
516
|
+
scanAttempts: integer(table, row, 'scan_attempts'),
|
|
517
|
+
dueSince: ownEntry(row, 'scan_after') instanceof Date ? date(table, row, 'scan_after') : date(table, row, 'created_at'),
|
|
518
|
+
})
|
|
519
|
+
)
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
async promote(id: string, sha256: string, version: string): Promise<boolean> {
|
|
523
|
+
scanning('verdict')
|
|
524
|
+
const changes = new Map([['status', 'live'], ['object_version', version]])
|
|
525
|
+
return await verdict(id, changes, allOf([held(status('quarantined')), compare(table, 'sha256', '=', sha256)]))
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
async deferScan(id: string, attempts: number, after: Date): Promise<boolean> {
|
|
529
|
+
scanning('retry')
|
|
530
|
+
const changes = new Map<string, number | Date>([['scan_attempts', attempts], ['scan_after', after]])
|
|
531
|
+
return await verdict(id, changes, allOf([status('quarantined'), compare(table, 'scan_attempts', '=', attempts - 1)]))
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
async requarantine(capability: unknown, version: string | null): Promise<void> {
|
|
535
|
+
scanning('quarantine')
|
|
536
|
+
const grant = grantOf(capability, spec.dimension, spec.actions.read)
|
|
537
|
+
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
538
|
+
const scanned = version === null ? isNull(table, 'object_version', false) : compare(table, 'object_version', '=', version)
|
|
539
|
+
const changes = new Map<string, string | number | Date | null>([
|
|
540
|
+
['status', 'quarantined'],
|
|
541
|
+
['object_version', null],
|
|
542
|
+
['scan_attempts', 0],
|
|
543
|
+
['scan_after', new Date()],
|
|
544
|
+
])
|
|
545
|
+
const node = updateNode(
|
|
546
|
+
table,
|
|
547
|
+
changes,
|
|
548
|
+
undefined,
|
|
549
|
+
scopedWhere(allOf([held(status('live')), scanned]), scope),
|
|
550
|
+
acknowledgement(table, 'id', undefined),
|
|
551
|
+
)
|
|
552
|
+
await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
reject(id: string): Promise<boolean> {
|
|
556
|
+
return verdict(id, new Map([['status', 'rejected']]), status('quarantined'))
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
async removable(limit: number, pendingBefore: Date): Promise<readonly RemovableFile[]> {
|
|
560
|
+
const scope = everything()
|
|
561
|
+
const rows = await session().run<Readonly<Record<string, unknown>>>(
|
|
562
|
+
selectNode(table, selections(table, ['id', 'object_key']), removable(pendingBefore), scope, limit),
|
|
563
|
+
guard('SelectQueryNode', table, scope),
|
|
564
|
+
)
|
|
565
|
+
return rows.map((row) => freeze({ id: text(table, row, 'id'), objectKey: text(table, row, 'object_key') }))
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
async purge(id: string, pendingBefore: Date): Promise<void> {
|
|
569
|
+
const scope = equals(table, 'id', id)
|
|
570
|
+
await session().run(deleteNode(table, scopedWhere(removable(pendingBefore), scope), 'id'), guard('DeleteQueryNode', table, scope))
|
|
571
|
+
},
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function fileTables(models: TableModels, runtime: () => DataLayerRuntime): FileTables {
|
|
576
|
+
return freeze({ collection: (spec: FileCollectionSpec) => collectionTable(models, runtime, spec) })
|
|
577
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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'
|
|
8
|
+
import { QueryRefusedError } from './errors'
|
|
9
|
+
import { FORM_ADMISSIONS_TABLE, FORM_CHALLENGES_TABLE } from './migrations'
|
|
10
|
+
import type { DataLayerRuntime } from './store'
|
|
11
|
+
|
|
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 {
|
|
19
|
+
/**
|
|
20
|
+
* Takes one from `bucket` of `form` in the database's current minute; `false` when `limit` were already taken
|
|
21
|
+
* there. The counter and the check are one statement.
|
|
22
|
+
*/
|
|
23
|
+
take(form: string, bucket: string, limit: number): Promise<boolean>
|
|
24
|
+
/**
|
|
25
|
+
* Records the digest of a challenge token `form` accepted, kept for `retainMs`; `false` when it was already
|
|
26
|
+
* recorded and has not expired, which makes the token's second use a replay.
|
|
27
|
+
*/
|
|
28
|
+
recordChallenge(form: string, tokenDigest: string, retainMs: number): Promise<boolean>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const A = FORM_ADMISSIONS_TABLE
|
|
32
|
+
const C = FORM_CHALLENGES_TABLE
|
|
33
|
+
|
|
34
|
+
/** Rows one statement prunes at most; every statement prunes, so expired rows never outgrow the live ones. */
|
|
35
|
+
const PRUNE_BATCH = 32
|
|
36
|
+
|
|
37
|
+
// `skip locked`: a prune never waits on a row another statement holds, so two admissions never deadlock. A prune never
|
|
38
|
+
// touches the row its own statement writes: the take's is the current minute's, and a record's is excluded by key.
|
|
39
|
+
const SQL_TAKE = `with pruned as (
|
|
40
|
+
delete from ${A} where (form, bucket, window_start) in (
|
|
41
|
+
select form, bucket, window_start from ${A} where window_start < date_trunc('minute', now())
|
|
42
|
+
limit ${PRUNE_BATCH} for update skip locked
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
insert into ${A} (form, bucket, window_start, count) values ($1, $2, date_trunc('minute', now()), 1)
|
|
46
|
+
on conflict (form, bucket, window_start) do update set count = ${A}.count + 1 where ${A}.count < $3::int
|
|
47
|
+
returning count`
|
|
48
|
+
|
|
49
|
+
const SQL_RECORD_CHALLENGE = `with pruned as (
|
|
50
|
+
delete from ${C} where (form, token_digest) in (
|
|
51
|
+
select form, token_digest from ${C} where expires_at <= now() and (form, token_digest) <> ($1, $2)
|
|
52
|
+
limit ${PRUNE_BATCH} for update skip locked
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
insert into ${C} (form, token_digest, expires_at) values ($1, $2, now() + $3::bigint * interval '1 millisecond')
|
|
56
|
+
on conflict (form, token_digest) do update set expires_at = excluded.expires_at where ${C}.expires_at <= now()
|
|
57
|
+
returning form`
|
|
58
|
+
|
|
59
|
+
function positiveInteger(value: number, what: string): number {
|
|
60
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
61
|
+
throw new QueryRefusedError('invalid_argument', `${what} must be a whole number of at least 1`)
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One take of `bucket` in `session`, which may be a transaction: rolled back with it, the take is given back. */
|
|
67
|
+
export async function takeFromBucket(session: Session, form: string, bucket: string, limit: number): Promise<boolean> {
|
|
68
|
+
const rows = await runFixedStatement(session, SQL_TAKE, [form, bucket, positiveInteger(limit, 'a form limit')])
|
|
69
|
+
return rows.length > 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formAdmissions(runtime: () => DataLayerRuntime): FormAdmissions {
|
|
73
|
+
const session = () => sessionOf(runtime())
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
take: (form: string, bucket: string, limit: number): Promise<boolean> => takeFromBucket(session(), form, bucket, limit),
|
|
76
|
+
|
|
77
|
+
async recordChallenge(form: string, tokenDigest: string, retainMs: number): Promise<boolean> {
|
|
78
|
+
const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [form, tokenDigest, positiveInteger(retainMs, 'a retention')])
|
|
79
|
+
return rows.length > 0
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|