@fabrika/db 0.4.0 → 0.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/db",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,8 +61,8 @@
61
61
  "test": "bun test"
62
62
  },
63
63
  "dependencies": {
64
- "@fabrika/auth-core": "^0.4.0",
65
- "@fabrika/platform-node": "^0.4.0",
64
+ "@fabrika/auth-core": "^0.4.1",
65
+ "@fabrika/platform-node": "^0.4.1",
66
66
  "kysely": "0.29.6",
67
67
  "kysely-postgres-js": "5.0.1",
68
68
  "zod": "^3.25.76"
package/src/orm-types.ts CHANGED
@@ -561,8 +561,13 @@ type RefFragment<Ix extends AnyDbIndex, D extends string, A extends string> = {
561
561
  : never
562
562
  }[TableName<Ix>]
563
563
 
564
- /** What a `Within` writes on a `'write'` site: the target of an UPDATE or DELETE whose action it holds. */
565
- type WithinWrites<E extends IndexedTable, M extends 'read' | 'write'> = M extends 'write' ? E['actions']['update'] | E['actions']['delete'] : never
564
+ /**
565
+ * What a `Within` writes on a `'write'` site: the target of an UPDATE or DELETE whose action it holds, a column's own
566
+ * write action included (the statement's SET is checked against its columns at run time).
567
+ */
568
+ type WithinWrites<E extends IndexedTable, M extends 'read' | 'write'> = M extends 'write'
569
+ ? E['actions']['update'] | E['actions']['delete'] | (keyof E['writes'] & string)
570
+ : never
566
571
 
567
572
  /** The tables a fragment of a `Within` of `D` and `A` reaches: those it lists by their read action, or writes on `M`. */
568
573
  type WithinFragment<Ix extends AnyDbIndex, D extends string, A extends string, M extends 'read' | 'write'> = {
package/src/orm.ts CHANGED
@@ -125,11 +125,11 @@ export function dataAccess(parts: DataLayerParts): DataAccess {
125
125
  sqlTag(
126
126
  site,
127
127
  parts.runtime().database.schema,
128
- (capability, table, use) => {
128
+ (capability, table, use, assigned) => {
129
129
  if (!hasOwn(parts.models, table)) {
130
130
  throw new QueryRefusedError('unknown_table', `ctx.sql.scope: ${table} is not a table of src/db/tables.ts`)
131
131
  }
132
- return scopeTerms(parts.contextOf(table), capability, use)
132
+ return scopeTerms(parts.contextOf(table), capability, use, assigned)
133
133
  },
134
134
  (table) => {
135
135
  if (modelOf(parts.models, table)?.scope.mode === 'reference') {
package/src/sql.ts CHANGED
@@ -46,9 +46,10 @@ export type ScopeUse = 'read' | 'update' | 'delete'
46
46
 
47
47
  /**
48
48
  * The scope of `capability` on `table` for `use`, as the data layer predicates its handles; throws when it does not
49
- * reach it for that use.
49
+ * reach it for that use. `assigned` is the columns an UPDATE's SET names (none when the SET cannot be read with
50
+ * certainty), and `undefined` before a statement is known.
50
51
  */
51
- export type ScopeOf = (capability: unknown, table: string, use: ScopeUse) => ScopeTerms
52
+ export type ScopeOf = (capability: unknown, table: string, use: ScopeUse, assigned?: readonly string[]) => ScopeTerms
52
53
 
53
54
  /** The operation a write statement performs on one of its targets. */
54
55
  export type WriteOperation = 'insert' | 'update' | 'delete'
@@ -392,11 +393,13 @@ interface Target {
392
393
  readonly operation: WriteOperation
393
394
  }
394
395
 
395
- /** The relation an UPDATE or DELETE writes: its table, under its alias (the table's name without one). */
396
+ /** The relation an UPDATE or DELETE writes: its table, under its alias (the table's name without one), and what it sets. */
396
397
  interface Written {
397
398
  readonly table: string
398
399
  readonly alias: string
399
400
  readonly operation: 'update' | 'delete'
401
+ /** The columns an UPDATE's SET assigns; none for a DELETE. */
402
+ readonly assigned: readonly string[]
400
403
  }
401
404
 
402
405
  /** What a statement writes, and for each interpolation, in order, the UPDATE or DELETE of the query block it stands in. */
@@ -466,22 +469,140 @@ function statementWrites(tokens: readonly SqlToken[]): StatementWrites {
466
469
  }
467
470
  } else if (word === 'DELETE' && next === 'FROM') {
468
471
  const target = targetAt(tokens, index + 2)
469
- enter(target === undefined ? undefined : { ...target, operation: 'delete' })
472
+ enter(target === undefined ? undefined : { ...target, operation: 'delete', assigned: [] })
470
473
  if (target !== undefined) targets.push({ table: target.table, operation: 'delete' })
471
474
  } else if (word === 'UPDATE') {
472
475
  const before = upperWord(tokens[index - 1])
473
476
  if (before === 'FOR' || before === 'KEY') continue
474
477
  const target = before === 'DO' ? inserts.get(depth) : targetAt(tokens, index + 1)
475
- enter(target === undefined ? undefined : { ...target, operation: 'update' })
478
+ enter(target === undefined ? undefined : { ...target, operation: 'update', assigned: assignedColumns(tokens, index + 1) })
476
479
  if (target !== undefined) targets.push({ table: target.table, operation: 'update' })
477
480
  }
478
481
  }
479
482
  return { targets, blocks }
480
483
  }
481
484
 
485
+ /** Words that end an UPDATE's SET list at its own depth, outside a CASE; `IS [NOT] DISTINCT FROM` does not. */
486
+ const SET_ENDS: ReadonlySet<string> = new Set(['FROM', 'WHERE', 'RETURNING'])
487
+
488
+ /**
489
+ * The columns the SET of the UPDATE whose target starts at `from` assigns: `a = …` and `(a, b) = …` items, a column's
490
+ * subfield or subscript by the column. A SET it cannot read to its end with certainty assigns no column it can name,
491
+ * which refuses a column's own write action: an item that is not a name, an unmatched bracket, a CASE without END.
492
+ */
493
+ function assignedColumns(tokens: readonly SqlToken[], from: number): readonly string[] {
494
+ const set = setKeyword(tokens, from)
495
+ if (set === undefined) return []
496
+ const columns: string[] = []
497
+ let index = set + 1
498
+ for (;;) {
499
+ const item = assignmentTarget(tokens, index)
500
+ const end = item === undefined ? undefined : expressionEnd(tokens, item.equals + 1)
501
+ if (item === undefined || end === undefined) return []
502
+ columns.push(...item.columns)
503
+ if (!isPunct(tokens[end], ',')) return columns
504
+ index = end + 1
505
+ }
506
+ }
507
+
508
+ /**
509
+ * The index of what ends the assigned expression that starts at `index`: the `,` before the next item, or the end of the
510
+ * SET list (`FROM`, `WHERE`, `RETURNING`, the `)` around the statement, `;`, the end of the text). A clause word inside
511
+ * brackets or a CASE, and the `FROM` of `IS [NOT] DISTINCT FROM`, belong to the expression. `undefined` when a bracket
512
+ * or a CASE does not close before the end.
513
+ */
514
+ function expressionEnd(tokens: readonly SqlToken[], index: number): number | undefined {
515
+ let depth = 0
516
+ let cases = 0
517
+ for (let position = index; position < tokens.length; position++) {
518
+ const token = tokens[position]
519
+ const word = upperWord(token)
520
+ if (isPunct(token, '(') || isPunct(token, '[')) {
521
+ depth++
522
+ } else if (isPunct(token, ')') || isPunct(token, ']')) {
523
+ if (--depth < 0) return cases === 0 && isPunct(token, ')') ? position : undefined
524
+ } else if (depth > 0) {
525
+ continue
526
+ } else if (word === 'CASE') {
527
+ cases++
528
+ } else if (word === 'END') {
529
+ if (--cases < 0) return undefined
530
+ } else if (isPunct(token, ',') || isPunct(token, ';')) {
531
+ return cases === 0 ? position : undefined
532
+ } else if (cases === 0 && word !== undefined && SET_ENDS.has(word) && !distinctFrom(tokens, position)) {
533
+ return position
534
+ }
535
+ }
536
+ return depth === 0 && cases === 0 ? tokens.length : undefined
537
+ }
538
+
539
+ /** The index of the SET of the UPDATE whose target starts at `from`, at the UPDATE's depth. */
540
+ function setKeyword(tokens: readonly SqlToken[], from: number): number | undefined {
541
+ let depth = 0
542
+ for (let index = from; index < tokens.length; index++) {
543
+ const token = tokens[index]
544
+ const word = upperWord(token)
545
+ if (depth === 0 && word === 'SET') return index
546
+ if (isPunct(token, '(')) depth++
547
+ if (isPunct(token, ')') && --depth < 0) return undefined
548
+ if (depth === 0 && word !== undefined && SET_ENDS.has(word)) return undefined
549
+ }
550
+ return undefined
551
+ }
552
+
553
+ /** Whether the `FROM` at `index` ends `IS [NOT] DISTINCT FROM`, a comparison, not a clause. */
554
+ function distinctFrom(tokens: readonly SqlToken[], index: number): boolean {
555
+ if (upperWord(tokens[index]) !== 'FROM' || upperWord(tokens[index - 1]) !== 'DISTINCT') return false
556
+ const before = upperWord(tokens[index - 2])
557
+ return before === 'IS' || (before === 'NOT' && upperWord(tokens[index - 3]) === 'IS')
558
+ }
559
+
560
+ /**
561
+ * The columns one SET item at `index` assigns and the index of its `=`: a name with any `.field` and `[…]` after it,
562
+ * or a parenthesized list of them. `undefined` when the item has no such form.
563
+ */
564
+ function assignmentTarget(tokens: readonly SqlToken[], index: number): { readonly columns: readonly string[]; readonly equals: number } | undefined {
565
+ if (!isPunct(tokens[index], '(')) {
566
+ const name = nameText(tokens[index])
567
+ if (name === undefined) return undefined
568
+ const equals = afterIndirection(tokens, index + 1)
569
+ return equals !== undefined && isPunct(tokens[equals], '=') ? { columns: [name], equals } : undefined
570
+ }
571
+ const columns: string[] = []
572
+ let position = index
573
+ do {
574
+ const name = nameText(tokens[position + 1])
575
+ const next = name === undefined ? undefined : afterIndirection(tokens, position + 2)
576
+ if (name === undefined || next === undefined) return undefined
577
+ columns.push(name)
578
+ position = next
579
+ } while (isPunct(tokens[position], ','))
580
+ return isPunct(tokens[position], ')') && isPunct(tokens[position + 1], '=') ? { columns, equals: position + 1 } : undefined
581
+ }
582
+
583
+ /** The index past a column's `.field` and `[…]` subscripts from `index`, or `undefined` when a bracket never closes. */
584
+ function afterIndirection(tokens: readonly SqlToken[], index: number): number | undefined {
585
+ let position = index
586
+ for (;;) {
587
+ if (isPunct(tokens[position], '.') && nameText(tokens[position + 1]) !== undefined) {
588
+ position += 2
589
+ } else if (isPunct(tokens[position], '[')) {
590
+ let depth = 0
591
+ for (; position < tokens.length; position++) {
592
+ if (isPunct(tokens[position], '[')) depth++
593
+ if (isPunct(tokens[position], ']') && --depth === 0) break
594
+ }
595
+ if (position >= tokens.length) return undefined
596
+ position++
597
+ } else {
598
+ return position
599
+ }
600
+ }
601
+ }
602
+
482
603
  /** What a fragment on `table` under `alias` is to its block: the relation its UPDATE or DELETE writes, or else read. */
483
- function fragmentUse(written: Written | undefined, table: string, alias: string): ScopeUse {
484
- return written !== undefined && written.table === table && written.alias === alias ? written.operation : 'read'
604
+ function fragmentUse(written: Written | undefined, table: string, alias: string): Written | undefined {
605
+ return written !== undefined && written.table === table && written.alias === alias ? written : undefined
485
606
  }
486
607
 
487
608
  /** Words that open the clause a scope may stand in. */
@@ -640,7 +761,10 @@ function expandScopes(
640
761
  if (fragment.site !== site) {
641
762
  refuse('sql_scope', 'a ctx.sql.scope(…) of another invocation or site is refused; build it with this ctx.sql')
642
763
  }
643
- const terms = termsOf(fragment.capability, fragment.table, fragmentUse(writes.blocks[index], fragment.table, fragment.alias))
764
+ const written = fragmentUse(writes.blocks[index], fragment.table, fragment.alias)
765
+ const terms = written === undefined
766
+ ? termsOf(fragment.capability, fragment.table, 'read')
767
+ : termsOf(fragment.capability, fragment.table, written.operation, written.assigned)
644
768
  const rendered = scopeFragment(terms, fragment.alias)
645
769
  expanded[expanded.length - 1] += rendered.parts[0] ?? ''
646
770
  for (let position = 0; position < rendered.values.length; position++) {
package/src/store.ts CHANGED
@@ -90,6 +90,7 @@ import {
90
90
  type TableModels,
91
91
  tableModels,
92
92
  type Tables,
93
+ updatableBy,
93
94
  updateActions,
94
95
  type UpdateValues,
95
96
  type VersionArguments,
@@ -340,9 +341,11 @@ export function reachPredicate(context: TableContext, capability: unknown): Oper
340
341
 
341
342
  /**
342
343
  * A fragment on an UPDATE or DELETE target: the capability's action is that operation's, and an application table is
343
- * written by an `app` capability only, a dimension or child table never by one.
344
+ * written by an `app` capability only, a dimension or child table never by one. An UPDATE may instead hold a column's
345
+ * own write action when every column it assigns declares that action (`updatingRef`'s rule); `assigned` is
346
+ * `undefined` before the statement is known, and then any such action is taken.
344
347
  */
345
- function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'update' | 'delete'): void {
348
+ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'update' | 'delete', assigned: readonly string[] | undefined): void {
346
349
  const model = context.model
347
350
  const statement = `${use === 'update' ? 'UPDATE' : 'DELETE'} of ${model.name}`
348
351
  if (grant.kind === 'app' && model.scope.mode !== 'app') {
@@ -352,11 +355,39 @@ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'u
352
355
  throw new CapabilityError('table_kind', `a scoped site writes no application table: ${statement} needs the capability of an app endpoint`)
353
356
  }
354
357
  const action = model.actions[use]
355
- if (action === undefined) {
358
+ if (grant.action === action) {
359
+ return
360
+ }
361
+ const columnWrites: string[] = []
362
+ const updates = use === 'update' ? updateActions(model) : []
363
+ for (let index = 0; index < updates.length; index++) {
364
+ const write = updates[index]
365
+ if (write !== undefined && write !== action) columnWrites[columnWrites.length] = write
366
+ }
367
+ if (action === undefined && columnWrites.length === 0) {
356
368
  throw new CapabilityError('undeclared_operation', `${statement}: its policy declares no ${use} action`)
357
369
  }
358
- if (grant.action !== action) {
359
- throw new CapabilityError('wrong_action', `${statement} needs its ${use} action ${action}; the capability holds ${grant.action}`)
370
+ if (!columnWrites.includes(grant.action)) {
371
+ const needs = action === undefined ? [] : [`its ${use} action ${action}`]
372
+ if (columnWrites.length > 0) needs[needs.length] = `a column's own write action (${columnWrites.join(', ')})`
373
+ throw new CapabilityError('wrong_action', `${statement} needs ${needs.join(' or ')}; the capability holds ${grant.action}`)
374
+ }
375
+ if (assigned === undefined) {
376
+ return
377
+ }
378
+ const written = updatableBy(model, grant.action)
379
+ const outside: string[] = assigned.length === 0 ? ['no column it can name'] : []
380
+ for (let index = 0; index < assigned.length; index++) {
381
+ const column = assigned[index]
382
+ if (column !== undefined && !inSet(written, column)) outside[outside.length] = column
383
+ }
384
+ if (outside.length > 0) {
385
+ throw new CapabilityError(
386
+ 'wrong_action',
387
+ `${statement} assigns ${
388
+ outside.join(', ')
389
+ }, which ${grant.action} does not write: a column's own write action scopes an UPDATE of its columns only`,
390
+ )
360
391
  }
361
392
  }
362
393
 
@@ -369,11 +400,11 @@ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'u
369
400
  * and every link of the chain it carries; a `Within` matches like `ctx.db.within` (its values, each container grant on
370
401
  * its own column, or every row for `all`); an `app` capability, and a scoped read of an application table, every row.
371
402
  */
372
- export function scopeTerms(context: TableContext, capability: unknown, use: ScopeUse): ScopeTerms {
403
+ export function scopeTerms(context: TableContext, capability: unknown, use: ScopeUse, assigned?: readonly string[]): ScopeTerms {
373
404
  const grant = runtimeOf(context.connection).capabilities.resolve(capability)
374
405
  const model = context.model
375
406
  if (use !== 'read') {
376
- checkWriteTarget(context, grant, use)
407
+ checkWriteTarget(context, grant, use, assigned)
377
408
  }
378
409
  if (grant.kind === 'app') {
379
410
  if (use === 'read') {