@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/orm.ts
CHANGED
|
@@ -10,8 +10,25 @@ import { type CapabilityGrant, describeGrant, type RefGrant } from './capability
|
|
|
10
10
|
import type { InvocationTransaction, Session } from './database'
|
|
11
11
|
import { columnOf, type Compare, type Contains, parseCondition, type Primitive, type SelectState, type TableShape } from './description'
|
|
12
12
|
import { CapabilityError, NotFoundError, type QueryRefusal, QueryRefusedError } from './errors'
|
|
13
|
+
import { answerFiles, type FileBinding } from './file-columns'
|
|
14
|
+
import { databaseNow } from './job-leases'
|
|
13
15
|
import { linksPredicate, linkValues } from './links'
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
aggregateNode,
|
|
18
|
+
allOf,
|
|
19
|
+
anyOf,
|
|
20
|
+
claimNode,
|
|
21
|
+
column,
|
|
22
|
+
cursorAlias,
|
|
23
|
+
equals,
|
|
24
|
+
everything,
|
|
25
|
+
inList,
|
|
26
|
+
lockedSelectNode,
|
|
27
|
+
scopedWhere,
|
|
28
|
+
selectNode,
|
|
29
|
+
updateNode,
|
|
30
|
+
withCursorColumns,
|
|
31
|
+
} from './nodes'
|
|
15
32
|
import type { DataKind, TargetForm, UnscopedMode } from './orm-types'
|
|
16
33
|
import { modelOf, namesAction, type TableModels, updatableBy, visibleFor } from './policy'
|
|
17
34
|
import { type SqlSite, type SqlTag, sqlTag } from './sql'
|
|
@@ -19,21 +36,26 @@ import {
|
|
|
19
36
|
actionShape,
|
|
20
37
|
aggregateRows,
|
|
21
38
|
type AggregateSpec,
|
|
39
|
+
answer,
|
|
22
40
|
appKeyPredicate,
|
|
23
41
|
appListPredicate,
|
|
24
42
|
appRecordPredicate,
|
|
25
43
|
appRowPredicate,
|
|
26
44
|
childObjectPredicate,
|
|
27
45
|
childScope,
|
|
46
|
+
claimedRowPredicate,
|
|
47
|
+
claimScope,
|
|
28
48
|
type DataLayerRuntime,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
insertIntoDimension,
|
|
49
|
+
heldAction,
|
|
50
|
+
insertPrepared,
|
|
32
51
|
insertRow,
|
|
33
52
|
keyOf,
|
|
34
53
|
objectPredicate,
|
|
35
54
|
ownerOf,
|
|
36
55
|
parentPredicate,
|
|
56
|
+
prepareApplicationInsert,
|
|
57
|
+
prepareChildInsert,
|
|
58
|
+
prepareDimensionInsert,
|
|
37
59
|
reachColumn,
|
|
38
60
|
reachPredicate,
|
|
39
61
|
readValues,
|
|
@@ -50,7 +72,7 @@ import {
|
|
|
50
72
|
updatingRef,
|
|
51
73
|
versionArgument,
|
|
52
74
|
} from './store'
|
|
53
|
-
import { answerDays } from './temporal'
|
|
75
|
+
import { answerDays, timestampInput } from './temporal'
|
|
54
76
|
|
|
55
77
|
/** A list page holds this many rows unless the caller asks for fewer, and never more than `MAX_LIST_LIMIT`. */
|
|
56
78
|
export const DEFAULT_LIST_LIMIT = 50
|
|
@@ -67,6 +89,21 @@ export interface DataSite {
|
|
|
67
89
|
readonly unscoped: Readonly<Record<string, UnscopedMode>>
|
|
68
90
|
/** Who an unscoped insert is stamped with (`createdBy`): no capability carries a principal for it. */
|
|
69
91
|
readonly principal: string
|
|
92
|
+
/** A job's schedule period, in ms: a queue claim's first backoff, doubled at each later claim of the row. */
|
|
93
|
+
readonly tickMs?: number
|
|
94
|
+
/**
|
|
95
|
+
* A public procedure, which anyone calls: its `ctx.db.app` inserts and reads nothing back, reads only under a table's
|
|
96
|
+
* read action, and reaches application tables only. It updates or removes only a versioned row whose id a verified
|
|
97
|
+
* signed token of its request carries (`carried`); it never locks or claims a row.
|
|
98
|
+
*/
|
|
99
|
+
readonly public?: true
|
|
100
|
+
/**
|
|
101
|
+
* Where the site's file references belong: each file column it reads answers one, and a file column it writes takes
|
|
102
|
+
* only one it holds. Absent where the framework serves no file; the columns then read as ids and refuse every write.
|
|
103
|
+
*/
|
|
104
|
+
readonly files?: FileBinding
|
|
105
|
+
/** The texts the verified signed tokens of a public procedure's request carry: the row ids it may update or remove. */
|
|
106
|
+
readonly carried?: ReadonlySet<string>
|
|
70
107
|
/** Throws unless the work running now is the invocation these handles were made for. */
|
|
71
108
|
check(): void
|
|
72
109
|
/** The session every statement of the site runs in. */
|
|
@@ -144,6 +181,7 @@ function runnerOf(site: DataSite): Runner {
|
|
|
144
181
|
return {
|
|
145
182
|
read: () => site.session(),
|
|
146
183
|
write: async (work) => work(await site.session()),
|
|
184
|
+
...(site.files === undefined ? {} : { files: site.files }),
|
|
147
185
|
}
|
|
148
186
|
}
|
|
149
187
|
|
|
@@ -234,6 +272,9 @@ function addTable(handle: Record<string, unknown>, table: string, rows: object):
|
|
|
234
272
|
|
|
235
273
|
function scopeTarget(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown, form: TargetForm): object {
|
|
236
274
|
const grant = resolved(parts, capability)
|
|
275
|
+
if (grant.kind === 'row' && form === 'on') {
|
|
276
|
+
return claimedRow(parts.contextOf(grant.table), site, run, capability)
|
|
277
|
+
}
|
|
237
278
|
if (grant.kind !== 'ref') {
|
|
238
279
|
throw new CapabilityError('wrong_capability', 'ctx.db.target is built from a Ref; an over endpoint lists through ctx.db.within')
|
|
239
280
|
}
|
|
@@ -282,7 +323,7 @@ function targetRow(
|
|
|
282
323
|
const onOnly = (operation: string) => `${operation} acts on one object: declare access with on`
|
|
283
324
|
const get = bound(site, async () => {
|
|
284
325
|
const grant = namedRef(context, capability, dimension)
|
|
285
|
-
return selectOne(context, run, targetPredicate(context, grant), visibleFor(context.model, grant.action))
|
|
326
|
+
return selectOne(context, run, targetPredicate(context, grant), visibleFor(context.model, grant.action), grant.action)
|
|
286
327
|
})
|
|
287
328
|
const lock = bound(site, async () => {
|
|
288
329
|
const grant = namedRef(context, capability, dimension)
|
|
@@ -294,6 +335,7 @@ function targetRow(
|
|
|
294
335
|
if (row === undefined) {
|
|
295
336
|
throw new NotFoundError(`${context.model.name} not found`)
|
|
296
337
|
}
|
|
338
|
+
await answerFiles(context.model, run.files, session, [row], grant.action)
|
|
297
339
|
return row
|
|
298
340
|
})
|
|
299
341
|
const getForUpdate = site.kind === 'job'
|
|
@@ -326,7 +368,7 @@ function childRows(context: TableContext, site: DataSite, run: Runner, capabilit
|
|
|
326
368
|
return freeze({
|
|
327
369
|
list: bound(site, async (options?: unknown) => {
|
|
328
370
|
const grant = namedRef(context, capability, parent)
|
|
329
|
-
return listRows(context, run, parentPredicate(context, grant), visibleFor(context.model, grant.action), options, [key])
|
|
371
|
+
return listRows(context, run, parentPredicate(context, grant), visibleFor(context.model, grant.action), options, [key], grant.action)
|
|
330
372
|
}),
|
|
331
373
|
get: bound(site, async (id: unknown) => {
|
|
332
374
|
const grant = namedRef(context, capability, parent)
|
|
@@ -334,9 +376,13 @@ function childRows(context: TableContext, site: DataSite, run: Runner, capabilit
|
|
|
334
376
|
throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its ${key}, a non-empty string`)
|
|
335
377
|
}
|
|
336
378
|
const scope = allOf([equals(context.model.name, key, id), parentPredicate(context, grant)])
|
|
337
|
-
return selectOne(context, run, scope, visibleFor(context.model, grant.action))
|
|
379
|
+
return selectOne(context, run, scope, visibleFor(context.model, grant.action), grant.action)
|
|
338
380
|
}),
|
|
339
|
-
insert: writing(
|
|
381
|
+
insert: writing(
|
|
382
|
+
site,
|
|
383
|
+
'insert',
|
|
384
|
+
async (values: unknown, options?: unknown) => insertPrepared(context, run, prepareChildInsert(context, capability, values, options)),
|
|
385
|
+
),
|
|
340
386
|
update: writing(site, 'update', async (id: unknown, values: unknown, ...options: unknown[]) => {
|
|
341
387
|
if (typeof id !== 'string' || id.length === 0) {
|
|
342
388
|
throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its parent Ref and its ${key}`)
|
|
@@ -358,15 +404,25 @@ function containedRows(context: TableContext, site: DataSite, run: Runner, capab
|
|
|
358
404
|
list: bound(site, async (options?: unknown) => {
|
|
359
405
|
const grant = namedRef(context, capability, dimension)
|
|
360
406
|
const scope = linksPredicate(context.model.name, linkValues(context.model.name, context.model.chain, grant))
|
|
361
|
-
return listRows(context, run, scope, visibleFor(context.model, grant.action), options, [keyOf(context.model)])
|
|
407
|
+
return listRows(context, run, scope, visibleFor(context.model, grant.action), options, [keyOf(context.model)], grant.action)
|
|
362
408
|
}),
|
|
363
|
-
insert: writing(
|
|
409
|
+
insert: writing(
|
|
410
|
+
site,
|
|
411
|
+
'insert',
|
|
412
|
+
async (values: unknown, options?: unknown) => insertPrepared(context, run, prepareDimensionInsert(context, capability, values, options)),
|
|
413
|
+
),
|
|
364
414
|
})
|
|
365
415
|
}
|
|
366
416
|
|
|
367
417
|
/** A top-level scope's own row, keyed by the scope's value: what an `in` or `creates` endpoint inserts. */
|
|
368
418
|
function ownerRow(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
|
|
369
|
-
return freeze({
|
|
419
|
+
return freeze({
|
|
420
|
+
insert: writing(
|
|
421
|
+
site,
|
|
422
|
+
'insert',
|
|
423
|
+
async (values: unknown, options?: unknown) => insertPrepared(context, run, prepareDimensionInsert(context, capability, values, options)),
|
|
424
|
+
),
|
|
425
|
+
})
|
|
370
426
|
}
|
|
371
427
|
|
|
372
428
|
function withinTables(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown): object {
|
|
@@ -381,20 +437,40 @@ function withinTables(parts: DataLayerParts, site: DataSite, run: Runner, capabi
|
|
|
381
437
|
const listable = model?.scope.mode === 'dimension' || model?.scope.mode === 'child'
|
|
382
438
|
if (model !== undefined && listable && reachColumn(model, grant.dimension) !== undefined) {
|
|
383
439
|
const context = parts.contextOf(model.name)
|
|
384
|
-
setOwn(handle, model.name, listedRows(context, site, run, () => reachPredicate(context, capability)))
|
|
440
|
+
setOwn(handle, model.name, listedRows(parts, context, site, run, capability, () => reachPredicate(context, capability)))
|
|
385
441
|
}
|
|
386
442
|
}
|
|
387
443
|
return freeze(handle)
|
|
388
444
|
}
|
|
389
445
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
-
|
|
446
|
+
/**
|
|
447
|
+
* A table read through a `Within` by one of its read actions, with the columns that action reads: `undefined` from
|
|
448
|
+
* `scope` is an empty `Within`, which reads nothing.
|
|
449
|
+
*/
|
|
450
|
+
function listedRows(
|
|
451
|
+
parts: DataLayerParts,
|
|
452
|
+
context: TableContext,
|
|
453
|
+
site: DataSite,
|
|
454
|
+
run: Runner,
|
|
455
|
+
capability: unknown,
|
|
456
|
+
scope: () => OperationNode | undefined,
|
|
457
|
+
): object {
|
|
393
458
|
const key = [keyOf(context.model)]
|
|
459
|
+
const visible = () => visibleFor(context.model, heldAction(context, capability))
|
|
394
460
|
return freeze({
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
461
|
+
claim: claiming(parts, context, site, capability),
|
|
462
|
+
list: bound(site, async (options?: unknown) => {
|
|
463
|
+
const predicate = scope()
|
|
464
|
+
return listRows(context, run, predicate, visible(), options, key, heldAction(context, capability))
|
|
465
|
+
}),
|
|
466
|
+
count: bound(site, async (options?: unknown) => {
|
|
467
|
+
const predicate = scope()
|
|
468
|
+
return countRows(context, run, predicate, visible(), options)
|
|
469
|
+
}),
|
|
470
|
+
aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => {
|
|
471
|
+
const predicate = scope()
|
|
472
|
+
return aggregateRows(context, run, predicate, actionShape(context.model, visible()), spec)
|
|
473
|
+
}),
|
|
398
474
|
})
|
|
399
475
|
}
|
|
400
476
|
|
|
@@ -412,8 +488,9 @@ function applicationTables(parts: DataLayerParts, site: DataSite, run: Runner, c
|
|
|
412
488
|
}
|
|
413
489
|
const context = parts.contextOf(model.name)
|
|
414
490
|
if (model.scope.mode === 'app') {
|
|
415
|
-
|
|
416
|
-
|
|
491
|
+
const rows = applicationRows(parts, context, site, run, capability)
|
|
492
|
+
setOwn(handle, model.name, site.public === true ? publicApplicationRows(context, site, run, capability, rows) : freeze(rows))
|
|
493
|
+
} else if (site.public !== true && (model.scope.mode === 'dimension' || model.scope.mode === 'child')) {
|
|
417
494
|
setOwn(handle, model.name, appRecordRows(context, site, run, capability, grant.action))
|
|
418
495
|
}
|
|
419
496
|
}
|
|
@@ -430,7 +507,10 @@ function appRecordRows(context: TableContext, site: DataSite, run: Runner, capab
|
|
|
430
507
|
const shape = actionShape(context.model, visible)
|
|
431
508
|
const key = [keyOf(context.model)]
|
|
432
509
|
return freeze({
|
|
433
|
-
list: bound(
|
|
510
|
+
list: bound(
|
|
511
|
+
site,
|
|
512
|
+
async (options?: unknown) => listRows(context, run, appRecordPredicate(context, capability, 'rows'), visible, options, key, action),
|
|
513
|
+
),
|
|
434
514
|
count: bound(site, async (options?: unknown) => countRows(context, run, appRecordPredicate(context, capability, 'counts'), visible, options)),
|
|
435
515
|
aggregate: bound(
|
|
436
516
|
site,
|
|
@@ -459,12 +539,12 @@ function scopedApplicationRows(context: TableContext, site: DataSite, run: Runne
|
|
|
459
539
|
const key = [keyOf(model)]
|
|
460
540
|
const scope = () => scopedApplicationRead(context, capability)
|
|
461
541
|
return freeze({
|
|
462
|
-
list: bound(site, async (options?: unknown) => listRows(context, run, scope(), visible, options, key)),
|
|
542
|
+
list: bound(site, async (options?: unknown) => listRows(context, run, scope(), visible, options, key, heldAction(context, capability))),
|
|
463
543
|
count: bound(site, async (options?: unknown) => countRows(context, run, scope(), visible, options)),
|
|
464
544
|
aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, scope(), context.shape, spec)),
|
|
465
545
|
get: bound(site, async (id: unknown) => {
|
|
466
546
|
scope()
|
|
467
|
-
return selectOne(context, run, appKeyPredicate(model, id), visible)
|
|
547
|
+
return selectOne(context, run, appKeyPredicate(model, id), visible, heldAction(context, capability))
|
|
468
548
|
}),
|
|
469
549
|
})
|
|
470
550
|
}
|
|
@@ -489,10 +569,16 @@ function namedApp(context: TableContext, capability: unknown): string {
|
|
|
489
569
|
* An application table under an `app` capability: listed, counted and aggregated with its read action; one row read
|
|
490
570
|
* (and locked) with any action it names; inserted, updated and removed with that operation's action.
|
|
491
571
|
*/
|
|
492
|
-
function applicationRows(
|
|
572
|
+
function applicationRows(
|
|
573
|
+
parts: DataLayerParts,
|
|
574
|
+
context: TableContext,
|
|
575
|
+
site: DataSite,
|
|
576
|
+
run: Runner,
|
|
577
|
+
capability: unknown,
|
|
578
|
+
): Readonly<Record<string, unknown>> {
|
|
493
579
|
const model = context.model
|
|
494
580
|
const table = model.name
|
|
495
|
-
const visible = model
|
|
581
|
+
const visible = () => visibleFor(model, heldAction(context, capability))
|
|
496
582
|
const key = [keyOf(model)]
|
|
497
583
|
const getForUpdate = site.kind === 'job'
|
|
498
584
|
? refused(site, 'read_only', 'a job writes inside ctx.transaction((tx) => …): use tx.db there')
|
|
@@ -500,27 +586,39 @@ function applicationRows(context: TableContext, site: DataSite, run: Runner, cap
|
|
|
500
586
|
? refused(site, 'read_only', 'getForUpdate locks the row, so it runs only in a mutation: declare the endpoint with r.mutation')
|
|
501
587
|
: bound(site, async (id: unknown) => {
|
|
502
588
|
const scope = appKeyPredicate(model, id)
|
|
503
|
-
const
|
|
504
|
-
const
|
|
589
|
+
const action = namedApp(context, capability)
|
|
590
|
+
const node = lockedSelectNode(table, visibleFor(model, action), scope, model.dates)
|
|
591
|
+
const session = await run.read()
|
|
592
|
+
const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'update' })
|
|
505
593
|
const [row] = answerDays(table, model.dates, rows)
|
|
506
594
|
if (row === undefined) {
|
|
507
595
|
throw new NotFoundError(`${table} not found`)
|
|
508
596
|
}
|
|
597
|
+
await answerFiles(model, run.files, session, [row], action)
|
|
509
598
|
return row
|
|
510
599
|
})
|
|
511
|
-
return
|
|
512
|
-
list: bound(
|
|
513
|
-
|
|
600
|
+
return {
|
|
601
|
+
list: bound(
|
|
602
|
+
site,
|
|
603
|
+
async (options?: unknown) =>
|
|
604
|
+
listRows(context, run, appListPredicate(context, capability), visible(), options, key, heldAction(context, capability)),
|
|
605
|
+
),
|
|
606
|
+
count: bound(site, async (options?: unknown) => countRows(context, run, appListPredicate(context, capability), visible(), options)),
|
|
514
607
|
aggregate: bound(
|
|
515
608
|
site,
|
|
516
|
-
async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, appListPredicate(context, capability),
|
|
609
|
+
async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, appListPredicate(context, capability), actionShape(model, visible()), spec),
|
|
517
610
|
),
|
|
518
611
|
get: bound(site, async (id: unknown) => {
|
|
519
612
|
const action = namedApp(context, capability)
|
|
520
|
-
return selectOne(context, run, appKeyPredicate(model, id), visibleFor(model, action))
|
|
613
|
+
return selectOne(context, run, appKeyPredicate(model, id), visibleFor(model, action), action)
|
|
521
614
|
}),
|
|
522
615
|
getForUpdate,
|
|
523
|
-
|
|
616
|
+
claim: claiming(parts, context, site, capability),
|
|
617
|
+
insert: writing(
|
|
618
|
+
site,
|
|
619
|
+
'insert',
|
|
620
|
+
async (values: unknown, options?: unknown) => insertPrepared(context, run, prepareApplicationInsert(context, capability, values, options)),
|
|
621
|
+
),
|
|
524
622
|
update: writing(site, 'update', async (id: unknown, values: unknown, ...options: unknown[]) => {
|
|
525
623
|
const grant = updatingApp(context, capability)
|
|
526
624
|
const changes = readValues(values, updatableBy(model, grant.action), 'update', model)
|
|
@@ -529,9 +627,260 @@ function applicationRows(context: TableContext, site: DataSite, run: Runner, cap
|
|
|
529
627
|
remove: writing(site, 'remove', async (id: unknown, ...options: unknown[]) => {
|
|
530
628
|
await removeRow(context, run, { scope: appRowPredicate(context, capability, id, 'delete') }, versionArgument(context, options))
|
|
531
629
|
}),
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* An application table of a public procedure: listed, counted, aggregated and read by key under its read action only;
|
|
635
|
+
* inserted with only the new key and version answered; updated or removed only by an id a verified signed token of the
|
|
636
|
+
* request carries, under `expectVersion`, answering only the new version; never locked or claimed.
|
|
637
|
+
*/
|
|
638
|
+
function publicApplicationRows(
|
|
639
|
+
context: TableContext,
|
|
640
|
+
site: DataSite,
|
|
641
|
+
run: Runner,
|
|
642
|
+
capability: unknown,
|
|
643
|
+
rows: Readonly<Record<string, unknown>>,
|
|
644
|
+
): object {
|
|
645
|
+
const model = context.model
|
|
646
|
+
const refusal = (operation: string) =>
|
|
647
|
+
refused(site, 'public_reach', `a public procedure does not ${operation} a ${model.name} row: it inserts, and reads under the read action`)
|
|
648
|
+
/** A row a public procedure may change: one a verified token names, of a table whose rows carry a version. */
|
|
649
|
+
const tokenRow = (operation: string, id: unknown): void => {
|
|
650
|
+
if (model.version === undefined) {
|
|
651
|
+
throw new QueryRefusedError('public_reach', `a public procedure ${operation}s only a versioned row, and ${model.name} declares no version`)
|
|
652
|
+
}
|
|
653
|
+
if (typeof id !== 'string' || site.carried?.has(id) !== true) {
|
|
654
|
+
throw new QueryRefusedError(
|
|
655
|
+
'public_reach',
|
|
656
|
+
`a public procedure ${operation}s a ${model.name} row only by an id a verified signed token of its request carries`,
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return freeze({
|
|
661
|
+
list: rows['list'],
|
|
662
|
+
count: rows['count'],
|
|
663
|
+
aggregate: rows['aggregate'],
|
|
664
|
+
get: bound(site, async (id: unknown) => {
|
|
665
|
+
appListPredicate(context, capability)
|
|
666
|
+
const action = heldAction(context, capability)
|
|
667
|
+
return selectOne(context, run, appKeyPredicate(model, id), visibleFor(model, action), action)
|
|
668
|
+
}),
|
|
669
|
+
getForUpdate: refusal('lock'),
|
|
670
|
+
claim: refusal('claim'),
|
|
671
|
+
update: writing(site, 'update', async (id: unknown, values: unknown, ...options: unknown[]) => {
|
|
672
|
+
tokenRow('update', id)
|
|
673
|
+
const grant = updatingApp(context, capability)
|
|
674
|
+
const changes = readValues(values, updatableBy(model, grant.action), 'update', model)
|
|
675
|
+
const saved = await updateRow<Readonly<Record<string, unknown>>>(
|
|
676
|
+
context,
|
|
677
|
+
run,
|
|
678
|
+
{ scope: appKeyPredicate(model, id) },
|
|
679
|
+
changes,
|
|
680
|
+
versionArgument(context, options),
|
|
681
|
+
)
|
|
682
|
+
return freeze({ version: saved['version'] })
|
|
683
|
+
}),
|
|
684
|
+
remove: writing(site, 'remove', async (id: unknown, ...options: unknown[]) => {
|
|
685
|
+
tokenRow('remove', id)
|
|
686
|
+
await removeRow(context, run, { scope: appRowPredicate(context, capability, id, 'delete') }, versionArgument(context, options))
|
|
687
|
+
}),
|
|
688
|
+
insert: bound(site, async (values: unknown, options?: unknown) => {
|
|
689
|
+
const prepared = prepareApplicationInsert(context, capability, values, options)
|
|
690
|
+
if (prepared.conflict !== undefined && prepared.conflict.mode !== 'nothing') {
|
|
691
|
+
throw new QueryRefusedError('public_reach', `a public procedure's insert into ${model.name} takes onConflict do: 'nothing' only`)
|
|
692
|
+
}
|
|
693
|
+
return insertPrepared(context, run, prepared)
|
|
694
|
+
}),
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Queues: a claim's lease, then each row finished through its row.ref ───────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
/** A claim's first backoff where the site names no schedule period: one minute, the shortest cron period. */
|
|
701
|
+
const DEFAULT_TICK_MS = 60_000
|
|
702
|
+
/** The longest backoff: past it the doubling stops, so a row with many attempts is still due within a month. */
|
|
703
|
+
const MAX_BACKOFF_MS = 30 * 24 * 60 * 60_000
|
|
704
|
+
|
|
705
|
+
/** The backoff after a row's `attempts`-th claim: one tick, doubling with each further claim. */
|
|
706
|
+
function backoffMs(tickMs: number, attempts: number): number {
|
|
707
|
+
let backoff = tickMs
|
|
708
|
+
for (let claim = 1; claim < attempts && backoff < MAX_BACKOFF_MS; claim++) {
|
|
709
|
+
backoff *= 2
|
|
710
|
+
}
|
|
711
|
+
return Math.min(backoff, MAX_BACKOFF_MS)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** `claim`, where the site may lease: a job outside its transaction. Inside one a failure would roll the lease back. */
|
|
715
|
+
function claiming(parts: DataLayerParts, context: TableContext, site: DataSite, capability: unknown): (options?: unknown) => Promise<unknown> {
|
|
716
|
+
if (site.kind === 'query') {
|
|
717
|
+
return refused(site, 'claim_site', 'claim leases rows, so it runs only in a job')
|
|
718
|
+
}
|
|
719
|
+
if (site.kind === 'mutation') {
|
|
720
|
+
return refused(
|
|
721
|
+
site,
|
|
722
|
+
'claim_site',
|
|
723
|
+
'claim leases rows in a transaction of its own: a job calls it on ctx.db before ctx.transaction, never inside one or in a request',
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
return bound(site, (options?: unknown) => claimRows(parts, context, site, capability, options))
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Leases up to `limit` due rows in a transaction of its own, fenced by the job attempt's lease: each claimed row's
|
|
731
|
+
* attempts grow by one and its `due` moves by the backoff, so a failure or a crash leaves it due again later. Answers
|
|
732
|
+
* each row with the columns the capability's action reads, its new version and attempts, and its `ref`.
|
|
733
|
+
*/
|
|
734
|
+
async function claimRows(
|
|
735
|
+
parts: DataLayerParts,
|
|
736
|
+
context: TableContext,
|
|
737
|
+
site: DataSite,
|
|
738
|
+
capability: unknown,
|
|
739
|
+
input: unknown,
|
|
740
|
+
): Promise<readonly Readonly<Record<string, unknown>>[]> {
|
|
741
|
+
const model = context.model
|
|
742
|
+
const table = model.name
|
|
743
|
+
const { grant, scope } = claimScope(context, capability)
|
|
744
|
+
const queue = model.queue
|
|
745
|
+
const version = model.version
|
|
746
|
+
if (queue === undefined || version === undefined) {
|
|
747
|
+
throw new CapabilityError('undeclared_operation', `${table} is not a queue table: its policy declares no queue`)
|
|
748
|
+
}
|
|
749
|
+
const options = readOptions(input, ['limit', 'where'], 'claim')
|
|
750
|
+
if (options['limit'] === undefined) {
|
|
751
|
+
throw new QueryRefusedError('list_page', 'claim takes { limit }: attempts count claims, so claim only what the run will reach')
|
|
752
|
+
}
|
|
753
|
+
const limit = limitOf(options['limit'])
|
|
754
|
+
const visible = visibleFor(model, grant.action)
|
|
755
|
+
const conditions = conditionsOf(actionShape(model, visible), options['where'])
|
|
756
|
+
if (scope === undefined) {
|
|
757
|
+
return []
|
|
758
|
+
}
|
|
759
|
+
const key = keyOf(model)
|
|
760
|
+
const chain = model.chain.map((link) => link.column)
|
|
761
|
+
const selected = namesOf(nameSet([...visible, key, version, queue.attempts, ...chain]))
|
|
762
|
+
const tickMs = site.tickMs ?? DEFAULT_TICK_MS
|
|
763
|
+
const order = [{ column: queue.due, direction: 'asc' as const }, { column: key, direction: 'asc' as const }]
|
|
764
|
+
const leased = await parts.fenced(parts.runtime(), async (session) => {
|
|
765
|
+
const now = await databaseNow(session)
|
|
766
|
+
const node = claimNode(
|
|
767
|
+
rowState(table, conditions, order, limit),
|
|
768
|
+
selected,
|
|
769
|
+
{ due: queue.due, attempts: queue.attempts, now, maxAttempts: queue.maxAttempts },
|
|
770
|
+
scope,
|
|
771
|
+
model.dates,
|
|
772
|
+
)
|
|
773
|
+
const rows = await session.run<Readonly<Record<string, unknown>>>(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'claim' })
|
|
774
|
+
const byAttempts = new Map<number, string[]>()
|
|
775
|
+
for (const row of rows) {
|
|
776
|
+
const attempts = row[queue.attempts]
|
|
777
|
+
const id = row[key]
|
|
778
|
+
if (typeof attempts !== 'number' || !isSafeInteger(attempts) || typeof id !== 'string') {
|
|
779
|
+
throw new QueryRefusedError('unexpected_row', `${table}.${queue.attempts} must be an integer and ${table}.${key} text`)
|
|
780
|
+
}
|
|
781
|
+
byAttempts.set(attempts, [...(byAttempts.get(attempts) ?? []), id])
|
|
782
|
+
}
|
|
783
|
+
const claimed = new Map<string, { readonly version: number; readonly attempts: number; readonly due: Date }>()
|
|
784
|
+
for (const [attempts, ids] of byAttempts) {
|
|
785
|
+
const due = new Date(now.getTime() + backoffMs(tickMs, attempts + 1))
|
|
786
|
+
const changes = new Map<string, Primitive>([[queue.attempts, attempts + 1], [queue.due, due]])
|
|
787
|
+
const updated = await session.run<Readonly<Record<string, unknown>>>(
|
|
788
|
+
updateNode(table, changes, version, scopedWhere(inList(table, key, ids), scope), answer(context)),
|
|
789
|
+
{ root: 'UpdateQueryNode', table, joins: {}, scope },
|
|
790
|
+
)
|
|
791
|
+
for (const row of updated) {
|
|
792
|
+
const id = row['id']
|
|
793
|
+
const next = row['version']
|
|
794
|
+
if (typeof id !== 'string' || typeof next !== 'number' || !isSafeInteger(next)) {
|
|
795
|
+
throw new QueryRefusedError('unexpected_row', `${table}.${version} came back as ${typeof next}; a version column is INTEGER`)
|
|
796
|
+
}
|
|
797
|
+
claimed.set(id, { version: next, attempts: attempts + 1, due })
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return { rows: await answerFiles(model, site.files, session, answerDays(table, model.dates, rows), grant.action), claimed }
|
|
801
|
+
})
|
|
802
|
+
return leased.rows.map((row) => {
|
|
803
|
+
const id = String(row[key])
|
|
804
|
+
const lease = leased.claimed.get(id)
|
|
805
|
+
if (lease === undefined) {
|
|
806
|
+
throw new QueryRefusedError('unexpected_row', `the claim of ${table} updated no row ${id}`)
|
|
807
|
+
}
|
|
808
|
+
const links = new Map<string, string>()
|
|
809
|
+
for (const name of chain) {
|
|
810
|
+
links.set(name, String(row[name]))
|
|
811
|
+
}
|
|
812
|
+
const ref = runtimeOf(context.connection).capabilities.row({ table, action: grant.action, principal: grant.principal, id, chain: links })
|
|
813
|
+
const answered: Record<string, unknown> = {}
|
|
814
|
+
for (const name of visible) {
|
|
815
|
+
setOwn(answered, name, row[name])
|
|
816
|
+
}
|
|
817
|
+
setOwn(answered, version, lease.version)
|
|
818
|
+
setOwn(answered, 'version', lease.version)
|
|
819
|
+
if (inSet(nameSet(visible), queue.attempts)) {
|
|
820
|
+
setOwn(answered, queue.attempts, lease.attempts)
|
|
821
|
+
}
|
|
822
|
+
if (inSet(nameSet(visible), queue.due)) {
|
|
823
|
+
setOwn(answered, queue.due, lease.due)
|
|
824
|
+
}
|
|
825
|
+
setOwn(answered, 'ref', ref)
|
|
826
|
+
return freeze(answered)
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* `tx.db.on(row.ref)`: the claimed row, finished in the job's transaction. `update(values, { expectVersion, done })`
|
|
832
|
+
* writes the columns of the claim's action and, with `done: true`, clears `due` in the same statement; `remove` deletes
|
|
833
|
+
* it with the table's delete action; `requeue({ at })` makes it due at `at` (now by default) with no attempts.
|
|
834
|
+
*/
|
|
835
|
+
function claimedRow(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
|
|
836
|
+
const model = context.model
|
|
837
|
+
const queue = model.queue
|
|
838
|
+
if (queue === undefined) {
|
|
839
|
+
throw new CapabilityError('undeclared_operation', `${model.name} is not a queue table: its policy declares no queue`)
|
|
840
|
+
}
|
|
841
|
+
return freeze({
|
|
842
|
+
update: writing(site, 'update', async (values: unknown, input?: unknown) => {
|
|
843
|
+
const scope = claimedRowPredicate(context, capability, 'update')
|
|
844
|
+
const options = readOptions(input, ['expectVersion', 'done'], 'update')
|
|
845
|
+
const done = options['done']
|
|
846
|
+
if (done !== undefined && typeof done !== 'boolean') {
|
|
847
|
+
throw new QueryRefusedError('invalid_argument', 'update takes done: true to finish the row, or no done')
|
|
848
|
+
}
|
|
849
|
+
const changes = readValues(values, updatableBy(model, heldAction(context, capability)), 'update', model)
|
|
850
|
+
if (done === true) {
|
|
851
|
+
changes.set(queue.due, null)
|
|
852
|
+
}
|
|
853
|
+
return updateRow(context, run, { scope }, changes, versionArgument(context, [options]))
|
|
854
|
+
}),
|
|
855
|
+
remove: writing(site, 'remove', async (input?: unknown) => {
|
|
856
|
+
const scope = claimedRowPredicate(context, capability, 'delete')
|
|
857
|
+
const options = readOptions(input, ['expectVersion'], 'remove')
|
|
858
|
+
await removeRow(context, run, { scope }, versionArgument(context, [options]))
|
|
859
|
+
}),
|
|
860
|
+
requeue: writing(site, 'update', async (input?: unknown) => {
|
|
861
|
+
const scope = claimedRowPredicate(context, capability, 'update')
|
|
862
|
+
const options = readOptions(input, ['at'], 'requeue')
|
|
863
|
+
const at = options['at'] === undefined ? await databaseNow(await run.read()) : dueTime(model.name, queue.due, options['at'])
|
|
864
|
+
const changes = new Map<string, Primitive>([[queue.due, at], [queue.attempts, 0]])
|
|
865
|
+
return updateRow(context, run, { scope }, changes, undefined)
|
|
866
|
+
}),
|
|
532
867
|
})
|
|
533
868
|
}
|
|
534
869
|
|
|
870
|
+
/** A requeue's time: a valid `Date`, or ISO 8601 text with an offset. */
|
|
871
|
+
function dueTime(table: string, column: string, value: unknown): Date | string {
|
|
872
|
+
if (value instanceof Date) {
|
|
873
|
+
if (Number.isNaN(value.getTime())) {
|
|
874
|
+
throw new QueryRefusedError('invalid_value', `requeue takes at, a valid Date`)
|
|
875
|
+
}
|
|
876
|
+
return value
|
|
877
|
+
}
|
|
878
|
+
if (typeof value === 'string') {
|
|
879
|
+
return timestampInput(value, `${table}.${column}`)
|
|
880
|
+
}
|
|
881
|
+
throw new QueryRefusedError('invalid_argument', 'requeue takes { at?: Date | ISO 8601 text }')
|
|
882
|
+
}
|
|
883
|
+
|
|
535
884
|
function referenceTables(parts: DataLayerParts, site: DataSite, run: Runner): object {
|
|
536
885
|
const handle: Record<string, unknown> = {}
|
|
537
886
|
const tables = ownKeys(parts.models)
|
|
@@ -581,6 +930,8 @@ function unscopedRows(context: TableContext, site: DataSite, run: Runner, mode:
|
|
|
581
930
|
const table = model.name
|
|
582
931
|
const visible = model.visible
|
|
583
932
|
const keyed = model.scope.mode === 'dimension' || model.scope.mode === 'child' || model.scope.mode === 'app'
|
|
933
|
+
// An unscoped read reads through the table's read action.
|
|
934
|
+
const unscopedRead = model.actions.read[0] ?? ''
|
|
584
935
|
const byKey = (id: unknown): OperationNode => {
|
|
585
936
|
if (!keyed) {
|
|
586
937
|
throw new QueryRefusedError('row_address', `${table} has no key; read it with list`)
|
|
@@ -598,17 +949,22 @@ function unscopedRows(context: TableContext, site: DataSite, run: Runner, mode:
|
|
|
598
949
|
return writing(site, operation, method)
|
|
599
950
|
}
|
|
600
951
|
return freeze({
|
|
601
|
-
list: bound(
|
|
952
|
+
list: bound(
|
|
953
|
+
site,
|
|
954
|
+
async (options?: unknown) => listRows(context, run, everything(), visible, options, keyed ? [keyOf(model)] : visible, unscopedRead),
|
|
955
|
+
),
|
|
602
956
|
count: bound(site, async (options?: unknown) => countRows(context, run, everything(), visible, options)),
|
|
603
957
|
aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, everything(), context.shape, spec)),
|
|
604
958
|
get: bound(site, async (id: unknown) => {
|
|
605
959
|
const scope = everything()
|
|
606
960
|
const node = selectNode(rowState(table, [], [], 1), visible, scope, byKey(id), model.dates)
|
|
607
|
-
const
|
|
961
|
+
const session = await run.read()
|
|
962
|
+
const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope })
|
|
608
963
|
const [row] = answerDays(table, model.dates, rows)
|
|
609
964
|
if (row === undefined) {
|
|
610
965
|
throw new NotFoundError(`${table} not found`)
|
|
611
966
|
}
|
|
967
|
+
await answerFiles(model, run.files, session, [row], unscopedRead)
|
|
612
968
|
return row
|
|
613
969
|
}),
|
|
614
970
|
insert: write('insert', async (values: unknown) => insertUnscoped(context, run, site.principal, values)),
|
|
@@ -841,6 +1197,7 @@ async function listRows(
|
|
|
841
1197
|
visible: readonly string[],
|
|
842
1198
|
input: unknown,
|
|
843
1199
|
tiebreak: readonly string[],
|
|
1200
|
+
action?: string,
|
|
844
1201
|
): Promise<{ readonly items: Readonly<Record<string, unknown>>[]; readonly next: string | null }> {
|
|
845
1202
|
const table = context.model.name
|
|
846
1203
|
const options = readOptions(input, ['where', 'orderBy', 'limit', 'after'], 'list')
|
|
@@ -857,7 +1214,8 @@ async function listRows(
|
|
|
857
1214
|
table,
|
|
858
1215
|
order.map((term) => term.column),
|
|
859
1216
|
)
|
|
860
|
-
const
|
|
1217
|
+
const session = await run.read()
|
|
1218
|
+
const rows = await session.run<Readonly<Record<string, unknown>>>(node, {
|
|
861
1219
|
root: 'SelectQueryNode',
|
|
862
1220
|
table,
|
|
863
1221
|
joins: {},
|
|
@@ -865,8 +1223,9 @@ async function listRows(
|
|
|
865
1223
|
})
|
|
866
1224
|
const page = answerDays(table, context.model.dates, rows.slice(0, limit))
|
|
867
1225
|
const last = page[page.length - 1]
|
|
1226
|
+
const items = page.map((row) => withoutCursor(row, order))
|
|
868
1227
|
return {
|
|
869
|
-
items:
|
|
1228
|
+
items: action === undefined ? items : await answerFiles(context.model, run.files, session, items, action),
|
|
870
1229
|
next: rows.length > limit && last !== undefined ? encodeCursor(last, order) : null,
|
|
871
1230
|
}
|
|
872
1231
|
}
|