@fabrika/db 0.5.5 → 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/src/file-tables.ts
DELETED
|
@@ -1,622 +0,0 @@
|
|
|
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 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
|
-
|
|
68
|
-
/** A file whose upload completed: served when `live`, `409` while `quarantined`, gone when `rejected`. */
|
|
69
|
-
export type HeldStatus = Exclude<FileStatus, 'pending'>
|
|
70
|
-
|
|
71
|
-
export interface StoredFile {
|
|
72
|
-
readonly id: string
|
|
73
|
-
readonly objectKey: string
|
|
74
|
-
readonly size: number
|
|
75
|
-
readonly contentType: string
|
|
76
|
-
readonly originalName: string | null
|
|
77
|
-
readonly status: HeldStatus
|
|
78
|
-
/** The object version a scanning collection's file was scanned as; `null` before a verdict or without scanning. */
|
|
79
|
-
readonly objectVersion: string | null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** One row of an owner's file list. */
|
|
83
|
-
export interface ListedFile {
|
|
84
|
-
readonly id: string
|
|
85
|
-
readonly name: string | null
|
|
86
|
-
readonly type: string
|
|
87
|
-
readonly size: number
|
|
88
|
-
readonly status: HeldStatus
|
|
89
|
-
readonly createdAt: Date
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A keyset page of an owner's files, oldest first; `next` is the `after` of the following page. */
|
|
93
|
-
export interface FilePage {
|
|
94
|
-
readonly items: readonly ListedFile[]
|
|
95
|
-
readonly next: string | null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** What the scan pass needs of a quarantined file: its object, the size and digest its upload recorded, its tries. */
|
|
99
|
-
export interface QuarantinedFile {
|
|
100
|
-
readonly id: string
|
|
101
|
-
readonly objectKey: string
|
|
102
|
-
readonly size: number
|
|
103
|
-
readonly sha256: string
|
|
104
|
-
readonly contentType: string
|
|
105
|
-
/** Earlier passes that gave it no verdict, or whose rejection could not delete its object. */
|
|
106
|
-
readonly scanAttempts: number
|
|
107
|
-
/** Since when it waits: its quarantine, or the end of its last retry's wait. Oldest first, across collections. */
|
|
108
|
-
readonly dueSince: Date
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** A row whose object may be deleted: removed, orphaned by its owner's deletion, or pending past the grace period. */
|
|
112
|
-
export interface RemovableFile {
|
|
113
|
-
readonly id: string
|
|
114
|
-
readonly objectKey: string
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface FileCollectionTable {
|
|
118
|
-
/** Locks the owner where `owner` says it lies and inserts a `pending` row there: the intent to clean its object up. */
|
|
119
|
-
begin(owner: unknown, file: PendingFile): Promise<void>
|
|
120
|
-
/**
|
|
121
|
-
* Locks the owner again and ends the pending row in `status`: `live`, or `quarantined` in a scanning collection;
|
|
122
|
-
* `NotFoundError` when the owner moved or the row is gone.
|
|
123
|
-
*/
|
|
124
|
-
complete(owner: unknown, id: string, written: { readonly size: number; readonly sha256: string }, status: 'live' | 'quarantined'): Promise<void>
|
|
125
|
-
/** Deletes the caller's own pending row once its object is gone. */
|
|
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'>
|
|
135
|
-
/** The owner's files that hold a place in its quota: live, quarantined and rejected, until removed. */
|
|
136
|
-
countHeld(owner: unknown, using?: Session): Promise<number>
|
|
137
|
-
/** Marks a held file of `owner` removed. */
|
|
138
|
-
retract(owner: unknown, id: string, at: Date): Promise<void>
|
|
139
|
-
/**
|
|
140
|
-
* The held file `file` names, over its whole chain, in any held state; `NotFoundError` otherwise. `using` is the
|
|
141
|
-
* session of the site that reads it (a handler's invocation, a job's transaction); the request's own by default.
|
|
142
|
-
*/
|
|
143
|
-
find(file: unknown, using?: Session): Promise<StoredFile>
|
|
144
|
-
/** Marks the held file `file` names removed, in any held state. */
|
|
145
|
-
markRemoved(file: unknown, at: Date): Promise<void>
|
|
146
|
-
/**
|
|
147
|
-
* Marks every file of the owner `owner` (a `Ref` for the delete action) removed, in `using`, held files and pending
|
|
148
|
-
* uploads alike: an upload begun before the removal then fails its completion and cleans up. Answers how many.
|
|
149
|
-
*/
|
|
150
|
-
removeOwned(owner: unknown, at: Date, using: Session): Promise<number>
|
|
151
|
-
/** The containers of a held file, read from its row: its owner and the owner's chain. */
|
|
152
|
-
locate(id: string, using?: Session): Promise<ReadonlyMap<string, string> | null>
|
|
153
|
-
/** At most `limit` held files of the owner `owner` (a `Ref` for the read action) after the id `after`, oldest first. */
|
|
154
|
-
list(owner: unknown, page: { readonly after: string | null; readonly limit: number }, using?: Session): Promise<FilePage>
|
|
155
|
-
/**
|
|
156
|
-
* At most `limit` quarantined files that are neither removed nor orphaned and due at `now`, the longest waiting
|
|
157
|
-
* first, so neither files that keep failing nor a busy collection hold back the rest.
|
|
158
|
-
*/
|
|
159
|
-
quarantined(limit: number, now: Date): Promise<readonly QuarantinedFile[]>
|
|
160
|
-
/**
|
|
161
|
-
* Makes a quarantined file `live` when it is still quarantined, not removed, not orphaned and its recorded digest is
|
|
162
|
-
* `sha256`, and records `version`, the scanned object's. Only inside a job attempt's lease, which fences the write;
|
|
163
|
-
* `false` when no row matched.
|
|
164
|
-
*/
|
|
165
|
-
promote(id: string, sha256: string, version: string): Promise<boolean>
|
|
166
|
-
/** Records a try that gave no verdict, the `attempts`-th, and when the next may start; fenced like `promote`. */
|
|
167
|
-
deferScan(id: string, attempts: number, after: Date): Promise<boolean>
|
|
168
|
-
/**
|
|
169
|
-
* Quarantines a live file of a scanning collection again, when its object is no longer the version it was scanned
|
|
170
|
-
* as (`version`, `null` for a file that went live before the collection scanned): the download that saw it.
|
|
171
|
-
*/
|
|
172
|
-
requarantine(file: unknown, version: string | null): Promise<void>
|
|
173
|
-
/** Marks a quarantined file `rejected` once its object is deleted; fenced like `promote`. `false` when no row matched. */
|
|
174
|
-
reject(id: string): Promise<boolean>
|
|
175
|
-
/**
|
|
176
|
-
* At most `limit` rows whose objects may be deleted: a removed or orphaned file in any held state, and a pending row
|
|
177
|
-
* only when created before `pendingBefore`.
|
|
178
|
-
*/
|
|
179
|
-
removable(limit: number, pendingBefore: Date): Promise<readonly RemovableFile[]>
|
|
180
|
-
/** Deletes the row of a removable file; a held file's row, and a recent pending one, are never deleted. */
|
|
181
|
-
purge(id: string, pendingBefore: Date): Promise<void>
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export interface FileTables {
|
|
185
|
-
/** The table of one collection; throws `PolicyError` when its owner is not a dimension table it can carry. */
|
|
186
|
-
collection(spec: FileCollectionSpec): FileCollectionTable
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
interface Owner {
|
|
190
|
-
readonly model: TableModel
|
|
191
|
-
readonly dimension: string
|
|
192
|
-
readonly key: string
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function ownerOf(models: TableModels, spec: FileCollectionSpec): Owner {
|
|
196
|
-
const model = modelOf(models, spec.owner)
|
|
197
|
-
if (model === undefined || model.scope.mode !== 'dimension') {
|
|
198
|
-
throw new PolicyError(`files '${spec.name}': its owner ${spec.owner} is not a dimension table`)
|
|
199
|
-
}
|
|
200
|
-
for (const link of model.chain) {
|
|
201
|
-
if (FILE_COLUMNS.includes(link.column)) {
|
|
202
|
-
throw new PolicyError(`files '${spec.name}': the owner column ${link.column} has the name of a file column`)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return { model, dimension: model.scope.dimension, key: model.scope.key }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isNull(table: string, name: string, negated: boolean): OperationNode {
|
|
209
|
-
return BinaryOperationNode.create(column(table, name), OperatorNode.create(negated ? 'is not' : 'is'), ValueNode.createImmediate(null))
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function compare(table: string, name: string, operator: '=' | '<' | '>', value: string | number | Date): OperationNode {
|
|
213
|
-
return BinaryOperationNode.create(column(table, name), OperatorNode.create(operator), ValueNode.create(value))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function selectNode(
|
|
217
|
-
table: string,
|
|
218
|
-
selections: readonly SelectionNode[],
|
|
219
|
-
conditions: OperationNode,
|
|
220
|
-
scope: OperationNode,
|
|
221
|
-
limit?: number,
|
|
222
|
-
orderBy: readonly string[] = [],
|
|
223
|
-
): SelectQueryNode {
|
|
224
|
-
const node: SelectQueryNode = {
|
|
225
|
-
kind: 'SelectQueryNode',
|
|
226
|
-
from: FromNode.create([TableNode.create(table)]),
|
|
227
|
-
selections,
|
|
228
|
-
where: scopedWhere(conditions, scope),
|
|
229
|
-
orderBy: orderBy.length === 0
|
|
230
|
-
? undefined
|
|
231
|
-
: OrderByNode.create(orderBy.map((name) => OrderByItemNode.create(column(table, name), RawNode.createWithSql('asc')))),
|
|
232
|
-
limit: limit === undefined ? undefined : LimitNode.create(ValueNode.create(limit)),
|
|
233
|
-
}
|
|
234
|
-
return freeze(node)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function selections(table: string, names: readonly string[]): SelectionNode[] {
|
|
238
|
-
return names.map((name) => SelectionNode.create(column(table, name)))
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function guard(root: GuardContext['root'], table: string, scope: OperationNode | undefined, lock?: 'share'): GuardContext {
|
|
242
|
-
return lock === undefined ? { root, table, joins: {}, scope } : { root, table, joins: {}, scope, lock }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function text(table: string, row: Readonly<Record<string, unknown>>, name: string): string {
|
|
246
|
-
const value = ownEntry(row, name)
|
|
247
|
-
if (typeof value !== 'string') {
|
|
248
|
-
throw new QueryRefusedError('unexpected_row', `${table}.${name} came back as ${typeof value}`)
|
|
249
|
-
}
|
|
250
|
-
return value
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** BIGINT and `count` come back as strings. */
|
|
254
|
-
function integer(table: string, row: Readonly<Record<string, unknown>>, name: string): number {
|
|
255
|
-
const value = Number(ownEntry(row, name))
|
|
256
|
-
if (!Number.isSafeInteger(value) || value < 0) {
|
|
257
|
-
throw new QueryRefusedError('unexpected_row', `${table}.${name} is not a non-negative integer`)
|
|
258
|
-
}
|
|
259
|
-
return value
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const STORED_COLUMNS = ['id', 'object_key', 'size', 'content_type', 'original_name', 'status']
|
|
263
|
-
const SCANNED_COLUMNS = [...STORED_COLUMNS, 'object_version']
|
|
264
|
-
const LISTED_COLUMNS = ['id', 'original_name', 'content_type', 'size', 'status', 'created_at']
|
|
265
|
-
const HELD: readonly HeldStatus[] = ['live', 'quarantined', 'rejected']
|
|
266
|
-
|
|
267
|
-
function heldStatus(table: string, row: Readonly<Record<string, unknown>>): HeldStatus {
|
|
268
|
-
const status = ownEntry(row, 'status')
|
|
269
|
-
const held = HELD.find((candidate) => candidate === status)
|
|
270
|
-
if (held === undefined) {
|
|
271
|
-
throw new QueryRefusedError('unexpected_row', `${table}.status came back as a state no held file has`)
|
|
272
|
-
}
|
|
273
|
-
return held
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function nullableText(row: Readonly<Record<string, unknown>>, name: string): string | null {
|
|
277
|
-
const value = ownEntry(row, name)
|
|
278
|
-
return typeof value === 'string' ? value : null
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function date(table: string, row: Readonly<Record<string, unknown>>, name: string): Date {
|
|
282
|
-
const value = ownEntry(row, name)
|
|
283
|
-
if (!(value instanceof Date)) {
|
|
284
|
-
throw new QueryRefusedError('unexpected_row', `${table}.${name} came back as ${typeof value}`)
|
|
285
|
-
}
|
|
286
|
-
return value
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function collectionTable(models: TableModels, runtime: () => DataLayerRuntime, spec: FileCollectionSpec): FileCollectionTable {
|
|
290
|
-
const table = tableOf(spec.name)
|
|
291
|
-
const owner = ownerOf(models, spec)
|
|
292
|
-
/** The owner row itself: its key, then its chain. */
|
|
293
|
-
const ownerLinks: readonly Container[] = [{ dimension: owner.dimension, column: owner.key }, ...owner.model.chain]
|
|
294
|
-
/** A file row's columns for its owner: `owner_id`, then the owner's chain under the owner's column names. */
|
|
295
|
-
const fileOwnerLinks: readonly Container[] = [{ dimension: owner.dimension, column: 'owner_id' }, ...owner.model.chain]
|
|
296
|
-
const fileLinks: readonly Container[] = [{ dimension: spec.dimension, column: 'id' }, ...fileOwnerLinks]
|
|
297
|
-
const session = (): Session => sessionOf(runtime())
|
|
298
|
-
|
|
299
|
-
function grantOf(capability: unknown, dimension: string, action: string): RefGrant {
|
|
300
|
-
const grant = runtime().capabilities.resolve(capability)
|
|
301
|
-
if (grant.kind !== 'ref' || grant.dimension !== dimension || grant.action !== action) {
|
|
302
|
-
throw new CapabilityError(
|
|
303
|
-
'wrong_capability',
|
|
304
|
-
`files '${spec.name}' needs a Ref of ${dimension} for ${action}, got ${describeGrant(grant)} for ${grant.action}`,
|
|
305
|
-
)
|
|
306
|
-
}
|
|
307
|
-
return grant
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const isHeld = (): OperationNode => anyOf(HELD.map((status) => compare(table, 'status', '=', status)))
|
|
311
|
-
const status = (value: HeldStatus): OperationNode => compare(table, 'status', '=', value)
|
|
312
|
-
/** A held file that is neither removed nor orphaned: one a request may reach. */
|
|
313
|
-
const held = (state: OperationNode = isHeld()): OperationNode => allOf([state, isNull(table, 'deleted_at', false), isNull(table, 'owner_id', true)])
|
|
314
|
-
|
|
315
|
-
/** Removed or orphaned files in every held state, and pending rows older than `pendingBefore`; never an upload in progress. */
|
|
316
|
-
const removable = (pendingBefore: Date): OperationNode =>
|
|
317
|
-
anyOf([
|
|
318
|
-
ParensNode.create(allOf([isHeld(), anyOf([isNull(table, 'deleted_at', true), isNull(table, 'owner_id', false)])])),
|
|
319
|
-
ParensNode.create(allOf([compare(table, 'status', '=', 'pending'), compare(table, 'created_at', '<', pendingBefore)])),
|
|
320
|
-
])
|
|
321
|
-
|
|
322
|
-
/** A verdict of the scan pass: one conditional update, fenced by the attempt's lease. */
|
|
323
|
-
function scanning(operation: string): void {
|
|
324
|
-
if (!spec.scan) {
|
|
325
|
-
throw new QueryRefusedError('file_scan', `files '${spec.name}' does not scan; it has no ${operation}`)
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function verdict(id: string, changes: ReadonlyMap<string, string | number | Date | null>, conditions: OperationNode): Promise<boolean> {
|
|
330
|
-
const current = runtime()
|
|
331
|
-
const fence = current.capabilities.fence()
|
|
332
|
-
if (fence === null) {
|
|
333
|
-
throw new QueryRefusedError('file_scan', `files '${spec.name}': a scan verdict is written only inside the scan job's lease`)
|
|
334
|
-
}
|
|
335
|
-
const scope = equals(table, 'id', id)
|
|
336
|
-
const node = updateNode(table, changes, undefined, scopedWhere(conditions, scope), acknowledgement(table, 'id', undefined))
|
|
337
|
-
const updated = await fencedTransaction(sessionOf(current), fence, (transaction) => transaction.run(node, guard('UpdateQueryNode', table, scope)))
|
|
338
|
-
return updated.length > 0
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Locks the owner row `for share` where the Ref says it lies, in `transaction`; `NotFoundError` when it does not. */
|
|
342
|
-
async function lockOwner(transaction: Session, grant: RefGrant): Promise<void> {
|
|
343
|
-
const name = owner.model.name
|
|
344
|
-
const scope = linksPredicate(name, linkValues(name, ownerLinks, grant))
|
|
345
|
-
const rows = await transaction.run(lockNode(name, owner.key, scope), guard('SelectQueryNode', name, scope, 'share'))
|
|
346
|
-
if (rows.length === 0) {
|
|
347
|
-
throw new NotFoundError(`${name} ${grant.id} is not where its Ref says; obtain a fresh Ref`)
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function ownedFile(grant: RefGrant, id: string): OperationNode {
|
|
352
|
-
return allOf([equals(table, 'id', id), linksPredicate(table, linkValues(table, fileOwnerLinks, grant))])
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return freeze({
|
|
356
|
-
async begin(capability: unknown, file: PendingFile): Promise<void> {
|
|
357
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
358
|
-
const values = new Map<string, string | Date | null>([['id', file.id]])
|
|
359
|
-
for (const { link, value } of linkValues(table, fileOwnerLinks, grant)) {
|
|
360
|
-
values.set(link.column, value)
|
|
361
|
-
}
|
|
362
|
-
values.set('status', 'pending')
|
|
363
|
-
values.set('object_key', file.objectKey)
|
|
364
|
-
values.set('content_type', file.contentType)
|
|
365
|
-
values.set('original_name', file.originalName)
|
|
366
|
-
values.set('created_by', grant.principal)
|
|
367
|
-
values.set('created_at', file.createdAt)
|
|
368
|
-
await session().transaction(async (transaction) => {
|
|
369
|
-
await lockOwner(transaction, grant)
|
|
370
|
-
await transaction.run(insertNode(table, values, acknowledgement(table, 'id', undefined)), guard('InsertQueryNode', table, undefined))
|
|
371
|
-
})
|
|
372
|
-
},
|
|
373
|
-
|
|
374
|
-
async complete(
|
|
375
|
-
capability: unknown,
|
|
376
|
-
id: string,
|
|
377
|
-
written: { readonly size: number; readonly sha256: string },
|
|
378
|
-
ended: 'live' | 'quarantined',
|
|
379
|
-
): Promise<void> {
|
|
380
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
381
|
-
const scope = ownedFile(grant, id)
|
|
382
|
-
const pending = allOf([compare(table, 'status', '=', 'pending'), isNull(table, 'deleted_at', false)])
|
|
383
|
-
const changes = new Map<string, string | number | Date>([['status', ended], ['size', written.size], ['sha256', written.sha256]])
|
|
384
|
-
if (ended === 'quarantined') {
|
|
385
|
-
scanning('quarantine')
|
|
386
|
-
changes.set('scan_after', new Date())
|
|
387
|
-
}
|
|
388
|
-
const node = updateNode(table, changes, undefined, scopedWhere(pending, scope), acknowledgement(table, 'id', undefined))
|
|
389
|
-
await session().transaction(async (transaction) => {
|
|
390
|
-
await lockOwner(transaction, grant)
|
|
391
|
-
const updated = await transaction.run(node, guard('UpdateQueryNode', table, scope))
|
|
392
|
-
if (updated.length === 0) {
|
|
393
|
-
throw new NotFoundError(`the pending file ${id} is no longer where its owner's Ref says`)
|
|
394
|
-
}
|
|
395
|
-
})
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
async abandon(id: string): Promise<void> {
|
|
399
|
-
const scope = equals(table, 'id', id)
|
|
400
|
-
const where = scopedWhere(compare(table, 'status', '=', 'pending'), scope)
|
|
401
|
-
await session().run(deleteNode(table, where, 'id'), guard('DeleteQueryNode', table, scope))
|
|
402
|
-
},
|
|
403
|
-
|
|
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> {
|
|
430
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
431
|
-
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
432
|
-
const count = SelectionNode.create(
|
|
433
|
-
AliasNode.create(AggregateFunctionNode.create('count', [SelectAllNode.create()]), IdentifierNode.create('count')),
|
|
434
|
-
)
|
|
435
|
-
const conditions = allOf([isHeld(), isNull(table, 'deleted_at', false)])
|
|
436
|
-
const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
437
|
-
selectNode(table, [count], conditions, scope),
|
|
438
|
-
guard('SelectQueryNode', table, scope),
|
|
439
|
-
)
|
|
440
|
-
return row === undefined ? 0 : integer(table, row, 'count')
|
|
441
|
-
},
|
|
442
|
-
|
|
443
|
-
async retract(capability: unknown, id: string, at: Date): Promise<void> {
|
|
444
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.write)
|
|
445
|
-
const scope = ownedFile(grant, id)
|
|
446
|
-
const node = updateNode(
|
|
447
|
-
table,
|
|
448
|
-
new Map([['deleted_at', at]]),
|
|
449
|
-
undefined,
|
|
450
|
-
scopedWhere(held(), scope),
|
|
451
|
-
acknowledgement(table, 'id', undefined),
|
|
452
|
-
)
|
|
453
|
-
await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
454
|
-
},
|
|
455
|
-
|
|
456
|
-
async find(capability: unknown, using?: Session): Promise<StoredFile> {
|
|
457
|
-
const grant = grantOf(capability, spec.dimension, spec.actions.read)
|
|
458
|
-
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
459
|
-
const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
460
|
-
selectNode(table, selections(table, spec.scan ? SCANNED_COLUMNS : STORED_COLUMNS), held(), scope, 1),
|
|
461
|
-
guard('SelectQueryNode', table, scope),
|
|
462
|
-
)
|
|
463
|
-
if (row === undefined) {
|
|
464
|
-
throw new NotFoundError(`files '${spec.name}': the file is not where its Ref says`)
|
|
465
|
-
}
|
|
466
|
-
return freeze({
|
|
467
|
-
id: text(table, row, 'id'),
|
|
468
|
-
objectKey: text(table, row, 'object_key'),
|
|
469
|
-
size: integer(table, row, 'size'),
|
|
470
|
-
contentType: text(table, row, 'content_type'),
|
|
471
|
-
originalName: nullableText(row, 'original_name'),
|
|
472
|
-
status: heldStatus(table, row),
|
|
473
|
-
objectVersion: spec.scan ? nullableText(row, 'object_version') : null,
|
|
474
|
-
})
|
|
475
|
-
},
|
|
476
|
-
|
|
477
|
-
async markRemoved(capability: unknown, at: Date): Promise<void> {
|
|
478
|
-
const grant = grantOf(capability, spec.dimension, spec.actions.delete)
|
|
479
|
-
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
480
|
-
const node = updateNode(
|
|
481
|
-
table,
|
|
482
|
-
new Map([['deleted_at', at]]),
|
|
483
|
-
undefined,
|
|
484
|
-
scopedWhere(held(), scope),
|
|
485
|
-
acknowledgement(table, 'id', undefined),
|
|
486
|
-
)
|
|
487
|
-
const updated = await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
488
|
-
if (updated.length === 0) {
|
|
489
|
-
throw new NotFoundError(`files '${spec.name}': the file is not where its Ref says`)
|
|
490
|
-
}
|
|
491
|
-
},
|
|
492
|
-
|
|
493
|
-
async removeOwned(capability: unknown, at: Date, using: Session): Promise<number> {
|
|
494
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.delete)
|
|
495
|
-
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
496
|
-
const node = updateNode(
|
|
497
|
-
table,
|
|
498
|
-
new Map([['deleted_at', at]]),
|
|
499
|
-
undefined,
|
|
500
|
-
scopedWhere(held(anyOf([isHeld(), compare(table, 'status', '=', 'pending')])), scope),
|
|
501
|
-
acknowledgement(table, 'id', undefined),
|
|
502
|
-
)
|
|
503
|
-
const removed = await using.run(node, guard('UpdateQueryNode', table, scope))
|
|
504
|
-
return removed.length
|
|
505
|
-
},
|
|
506
|
-
|
|
507
|
-
async locate(id: string, using?: Session): Promise<ReadonlyMap<string, string> | null> {
|
|
508
|
-
const scope = equals(table, 'id', id)
|
|
509
|
-
const columns = fileOwnerLinks.map((link) => link.column)
|
|
510
|
-
const [row] = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
511
|
-
selectNode(table, selections(table, columns), held(), scope, 1),
|
|
512
|
-
guard('SelectQueryNode', table, scope),
|
|
513
|
-
)
|
|
514
|
-
if (row === undefined) {
|
|
515
|
-
return null
|
|
516
|
-
}
|
|
517
|
-
return new Map(fileOwnerLinks.map((link) => [link.dimension, text(table, row, link.column)]))
|
|
518
|
-
},
|
|
519
|
-
|
|
520
|
-
async list(capability: unknown, page: { readonly after: string | null; readonly limit: number }, using?: Session): Promise<FilePage> {
|
|
521
|
-
const grant = grantOf(capability, owner.dimension, spec.actions.read)
|
|
522
|
-
if (!Number.isSafeInteger(page.limit) || page.limit < 1) {
|
|
523
|
-
throw new QueryRefusedError('list_page', `files '${spec.name}': a page holds at least one file`)
|
|
524
|
-
}
|
|
525
|
-
const scope = linksPredicate(table, linkValues(table, fileOwnerLinks, grant))
|
|
526
|
-
const conditions = page.after === null ? held() : allOf([held(), compare(table, 'id', '>', page.after)])
|
|
527
|
-
const rows = await (using ?? session()).run<Readonly<Record<string, unknown>>>(
|
|
528
|
-
selectNode(table, selections(table, LISTED_COLUMNS), conditions, scope, page.limit + 1, ['id']),
|
|
529
|
-
guard('SelectQueryNode', table, scope),
|
|
530
|
-
)
|
|
531
|
-
const items = rows.slice(0, page.limit).map((row) =>
|
|
532
|
-
freeze({
|
|
533
|
-
id: text(table, row, 'id'),
|
|
534
|
-
name: nullableText(row, 'original_name'),
|
|
535
|
-
type: text(table, row, 'content_type'),
|
|
536
|
-
size: integer(table, row, 'size'),
|
|
537
|
-
status: heldStatus(table, row),
|
|
538
|
-
createdAt: date(table, row, 'created_at'),
|
|
539
|
-
})
|
|
540
|
-
)
|
|
541
|
-
const last = items[items.length - 1]
|
|
542
|
-
return freeze({ items, next: rows.length > page.limit && last !== undefined ? last.id : null })
|
|
543
|
-
},
|
|
544
|
-
|
|
545
|
-
async quarantined(limit: number, now: Date): Promise<readonly QuarantinedFile[]> {
|
|
546
|
-
scanning('scan batch')
|
|
547
|
-
const scope = everything()
|
|
548
|
-
const due = allOf([held(status('quarantined')), anyOf([isNull(table, 'scan_after', false), compare(table, 'scan_after', '<', now)])])
|
|
549
|
-
const columns = ['id', 'object_key', 'size', 'sha256', 'content_type', 'scan_attempts', 'scan_after', 'created_at']
|
|
550
|
-
const rows = await session().run<Readonly<Record<string, unknown>>>(
|
|
551
|
-
selectNode(table, selections(table, columns), due, scope, limit, ['scan_after', 'created_at']),
|
|
552
|
-
guard('SelectQueryNode', table, scope),
|
|
553
|
-
)
|
|
554
|
-
return rows.map((row) =>
|
|
555
|
-
freeze({
|
|
556
|
-
id: text(table, row, 'id'),
|
|
557
|
-
objectKey: text(table, row, 'object_key'),
|
|
558
|
-
size: integer(table, row, 'size'),
|
|
559
|
-
sha256: text(table, row, 'sha256'),
|
|
560
|
-
contentType: text(table, row, 'content_type'),
|
|
561
|
-
scanAttempts: integer(table, row, 'scan_attempts'),
|
|
562
|
-
dueSince: ownEntry(row, 'scan_after') instanceof Date ? date(table, row, 'scan_after') : date(table, row, 'created_at'),
|
|
563
|
-
})
|
|
564
|
-
)
|
|
565
|
-
},
|
|
566
|
-
|
|
567
|
-
async promote(id: string, sha256: string, version: string): Promise<boolean> {
|
|
568
|
-
scanning('verdict')
|
|
569
|
-
const changes = new Map([['status', 'live'], ['object_version', version]])
|
|
570
|
-
return await verdict(id, changes, allOf([held(status('quarantined')), compare(table, 'sha256', '=', sha256)]))
|
|
571
|
-
},
|
|
572
|
-
|
|
573
|
-
async deferScan(id: string, attempts: number, after: Date): Promise<boolean> {
|
|
574
|
-
scanning('retry')
|
|
575
|
-
const changes = new Map<string, number | Date>([['scan_attempts', attempts], ['scan_after', after]])
|
|
576
|
-
return await verdict(id, changes, allOf([status('quarantined'), compare(table, 'scan_attempts', '=', attempts - 1)]))
|
|
577
|
-
},
|
|
578
|
-
|
|
579
|
-
async requarantine(capability: unknown, version: string | null): Promise<void> {
|
|
580
|
-
scanning('quarantine')
|
|
581
|
-
const grant = grantOf(capability, spec.dimension, spec.actions.read)
|
|
582
|
-
const scope = linksPredicate(table, linkValues(table, fileLinks, grant))
|
|
583
|
-
const scanned = version === null ? isNull(table, 'object_version', false) : compare(table, 'object_version', '=', version)
|
|
584
|
-
const changes = new Map<string, string | number | Date | null>([
|
|
585
|
-
['status', 'quarantined'],
|
|
586
|
-
['object_version', null],
|
|
587
|
-
['scan_attempts', 0],
|
|
588
|
-
['scan_after', new Date()],
|
|
589
|
-
])
|
|
590
|
-
const node = updateNode(
|
|
591
|
-
table,
|
|
592
|
-
changes,
|
|
593
|
-
undefined,
|
|
594
|
-
scopedWhere(allOf([held(status('live')), scanned]), scope),
|
|
595
|
-
acknowledgement(table, 'id', undefined),
|
|
596
|
-
)
|
|
597
|
-
await session().run(node, guard('UpdateQueryNode', table, scope))
|
|
598
|
-
},
|
|
599
|
-
|
|
600
|
-
reject(id: string): Promise<boolean> {
|
|
601
|
-
return verdict(id, new Map([['status', 'rejected']]), status('quarantined'))
|
|
602
|
-
},
|
|
603
|
-
|
|
604
|
-
async removable(limit: number, pendingBefore: Date): Promise<readonly RemovableFile[]> {
|
|
605
|
-
const scope = everything()
|
|
606
|
-
const rows = await session().run<Readonly<Record<string, unknown>>>(
|
|
607
|
-
selectNode(table, selections(table, ['id', 'object_key']), removable(pendingBefore), scope, limit),
|
|
608
|
-
guard('SelectQueryNode', table, scope),
|
|
609
|
-
)
|
|
610
|
-
return rows.map((row) => freeze({ id: text(table, row, 'id'), objectKey: text(table, row, 'object_key') }))
|
|
611
|
-
},
|
|
612
|
-
|
|
613
|
-
async purge(id: string, pendingBefore: Date): Promise<void> {
|
|
614
|
-
const scope = equals(table, 'id', id)
|
|
615
|
-
await session().run(deleteNode(table, scopedWhere(removable(pendingBefore), scope), 'id'), guard('DeleteQueryNode', table, scope))
|
|
616
|
-
},
|
|
617
|
-
})
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
export function fileTables(models: TableModels, runtime: () => DataLayerRuntime): FileTables {
|
|
621
|
-
return freeze({ collection: (spec: FileCollectionSpec) => collectionTable(models, runtime, spec) })
|
|
622
|
-
}
|