@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 +3 -3
- package/src/orm-types.ts +7 -2
- package/src/orm.ts +2 -2
- package/src/sql.ts +132 -8
- package/src/store.ts +38 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/db",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
65
|
-
"@fabrika/platform-node": "^0.4.
|
|
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
|
-
/**
|
|
565
|
-
|
|
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):
|
|
484
|
-
return written !== undefined && written.table === table && written.alias === alias ? written
|
|
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
|
|
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 ===
|
|
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
|
|
359
|
-
|
|
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') {
|