@fabrika/db 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 { aggregateNode, allOf, anyOf, column, cursorAlias, equals, everything, lockedSelectNode, selectNode, withCursorColumns } from './nodes'
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
- insertIntoApplication,
30
- insertIntoChild,
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(site, 'insert', async (values: unknown) => insertIntoChild(context, run, capability, values)),
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(site, 'insert', async (values: unknown) => insertIntoDimension(context, run, capability, values)),
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({ insert: writing(site, 'insert', async (values: unknown) => insertIntoDimension(context, run, capability, values)) })
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
- /** A table read through a `Within` by its read action: `undefined` from `scope` is an empty `Within`, which reads nothing. */
391
- function listedRows(context: TableContext, site: DataSite, run: Runner, scope: () => OperationNode | undefined): object {
392
- const visible = context.model.visible
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
- list: bound(site, async (options?: unknown) => listRows(context, run, scope(), visible, options, key)),
396
- count: bound(site, async (options?: unknown) => countRows(context, run, scope(), visible, options)),
397
- aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, scope(), context.shape, spec)),
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
- setOwn(handle, model.name, applicationRows(context, site, run, capability))
416
- } else if (model.scope.mode === 'dimension' || model.scope.mode === 'child') {
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(site, async (options?: unknown) => listRows(context, run, appRecordPredicate(context, capability, 'rows'), visible, options, key)),
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(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
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.visible
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 node = lockedSelectNode(table, visibleFor(model, namedApp(context, capability)), scope, model.dates)
504
- const rows = await (await run.read()).run(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'update' })
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 freeze({
512
- list: bound(site, async (options?: unknown) => listRows(context, run, appListPredicate(context, capability), visible, options, key)),
513
- count: bound(site, async (options?: unknown) => countRows(context, run, appListPredicate(context, capability), visible, options)),
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), context.shape, spec),
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
- insert: writing(site, 'insert', async (values: unknown) => insertIntoApplication(context, run, capability, values)),
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(site, async (options?: unknown) => listRows(context, run, everything(), visible, options, keyed ? [keyOf(model)] : visible)),
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 rows = await (await run.read()).run(node, { root: 'SelectQueryNode', table, joins: {}, scope })
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 rows = await (await run.read()).run<Readonly<Record<string, unknown>>>(node, {
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: page.map((row) => withoutCursor(row, order)),
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
  }