@opensaas/stack-core 0.31.1 → 0.33.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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/access/access-filter.d.ts +76 -20
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +97 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +171 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +12 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +12 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +19 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +29 -0
  16. package/dist/access/errors.js.map +1 -0
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +10 -3
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +3 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +3 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +14 -8
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +1 -1
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +1 -5
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts.map +1 -1
  32. package/dist/context/transaction-boundary.js +43 -6
  33. package/dist/context/transaction-boundary.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/access/access-filter.test.ts +254 -7
  40. package/src/access/access-filter.ts +141 -72
  41. package/src/access/depth-limits.ts +11 -0
  42. package/src/access/errors.ts +32 -0
  43. package/src/access/field-visibility.ts +10 -3
  44. package/src/access/index.ts +4 -0
  45. package/src/context/index.ts +14 -5
  46. package/src/context/nested-operations.ts +0 -7
  47. package/src/context/transaction-boundary.ts +48 -7
  48. package/src/index.ts +6 -0
  49. package/tests/access-relationships.test.ts +77 -63
  50. package/tests/context.test.ts +106 -24
  51. package/tests/transaction-boundary-hooks.test.ts +246 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { getContext } from '../src/context/index.js'
3
3
  import { config, list } from '../src/config/index.js'
4
4
  import { text, relationship } from '../src/fields/index.js'
5
+ import { enumerateInvolvedLists } from '../src/context/transaction-boundary.js'
5
6
 
6
7
  /**
7
8
  * #590 / ADR-0010: transaction-boundary hooks (`beforeTransaction` /
@@ -17,12 +18,13 @@ import { text, relationship } from '../src/fields/index.js'
17
18
  * that sudo does not affect these hooks.
18
19
  */
19
20
 
20
- function createTxPrisma() {
21
+ function createTxPrisma(extraTables: string[] = []) {
21
22
  const tables: Record<string, Map<string, Record<string, unknown>>> = {
22
23
  post: new Map(),
23
24
  user: new Map(),
24
25
  comment: new Map(),
25
26
  }
27
+ for (const table of extraTables) tables[table] = new Map()
26
28
  let idCounter = 0
27
29
  const nextId = () => `id-${++idCounter}`
28
30
 
@@ -111,6 +113,7 @@ function createTxPrisma() {
111
113
  user: makeModel('user'),
112
114
  comment: makeModel('comment'),
113
115
  }
116
+ for (const table of extraTables) client[table] = makeModel(table)
114
117
 
115
118
  client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
116
119
  const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
@@ -463,3 +466,245 @@ describe('#590 transaction-boundary hooks', () => {
463
466
  expect(after).not.toHaveBeenCalled()
464
467
  })
465
468
  })
469
+
470
+ /**
471
+ * #835: `enumerateInvolvedLists`'s walk used to stop at a fixed depth cap
472
+ * (`MAX_DEPTH = 5`), so lists reachable only past it never entered the
473
+ * involved-list set and their transaction-boundary hooks silently never fired.
474
+ * The fix replaces the depth cap with a saturation bound computed from the
475
+ * CONFIG's relationship graph reachable from the top-level list: the walk
476
+ * stops once every (list, operation) pair it could ever find has been
477
+ * recorded, regardless of how deep the payload nests.
478
+ */
479
+ describe('#835 enumerateInvolvedLists — saturation-bound enumeration walk', () => {
480
+ function chainConfigLists(length: number) {
481
+ const lists: Record<string, ReturnType<typeof list>> = {}
482
+ for (let i = 1; i <= length; i++) {
483
+ const name = `L${i}`
484
+ const fields: Record<string, ReturnType<typeof text> | ReturnType<typeof relationship>> = {
485
+ name: text(),
486
+ }
487
+ if (i < length) {
488
+ fields[`l${i + 1}`] = relationship({ ref: `L${i + 1}` })
489
+ }
490
+ lists[name] = list({ fields })
491
+ }
492
+ return lists
493
+ }
494
+
495
+ function chainInputData(length: number): Record<string, unknown> {
496
+ let payload: Record<string, unknown> = { name: `r${length}` }
497
+ for (let i = length - 1; i >= 1; i--) {
498
+ payload = { name: `r${i}`, [`l${i + 1}`]: { create: payload } }
499
+ }
500
+ return payload
501
+ }
502
+
503
+ it('enumerates every list in an 8-list chain, deeper than the old fixed depth cap of 5', async () => {
504
+ const resolvedConfig = await config({
505
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
506
+ lists: chainConfigLists(8),
507
+ })
508
+
509
+ const involved = enumerateInvolvedLists({
510
+ listName: 'L1',
511
+ listConfig: resolvedConfig.lists.L1,
512
+ operation: 'create',
513
+ inputData: chainInputData(8),
514
+ topLevelOriginalItem: undefined,
515
+ config: resolvedConfig,
516
+ })
517
+
518
+ expect(involved.map((i) => i.listKey)).toEqual(['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8'])
519
+ expect(involved.map((i) => i.operation)).toEqual(Array(8).fill('create'))
520
+ // The top-level list is first and is the only one marked isTopLevel.
521
+ expect(involved[0].isTopLevel).toBe(true)
522
+ expect(involved.slice(1).every((i) => !i.isTopLevel)).toBe(true)
523
+ })
524
+
525
+ it('dedupes by (list, operation) when a list is nested many times in one payload', async () => {
526
+ const resolvedConfig = await config({
527
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
528
+ lists: {
529
+ Parent: list({
530
+ fields: { name: text(), children: relationship({ ref: 'Child', many: true }) },
531
+ }),
532
+ Child: list({ fields: { name: text() } }),
533
+ },
534
+ })
535
+
536
+ const involved = enumerateInvolvedLists({
537
+ listName: 'Parent',
538
+ listConfig: resolvedConfig.lists.Parent,
539
+ operation: 'create',
540
+ inputData: {
541
+ name: 'p',
542
+ children: { create: [{ name: 'c1' }, { name: 'c2' }, { name: 'c3' }] },
543
+ },
544
+ topLevelOriginalItem: undefined,
545
+ config: resolvedConfig,
546
+ })
547
+
548
+ // Three nested Child creates collapse into a single involvement — the
549
+ // hooks are a per-LIST compensation bracket, not per-record.
550
+ expect(involved.map((i) => `${i.listKey}:${i.operation}`)).toEqual([
551
+ 'Parent:create',
552
+ 'Child:create',
553
+ ])
554
+ })
555
+
556
+ it('stops descending once every reachable (list, operation) pair is recorded, without inspecting payload past that point', async () => {
557
+ // Node self-references, so the reachable closure from Node is just
558
+ // {Node} — the saturation bound is 1 list * 3 operations = 3 pairs.
559
+ // Extra1/Extra2 are unrelated lists in the same config: if the bound were
560
+ // ever computed from the WHOLE config instead of the graph reachable
561
+ // from the write's own top-level list, the bound would be inflated to 9
562
+ // and this test's trap (below) would fire.
563
+ const resolvedConfig = await config({
564
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
565
+ lists: {
566
+ Node: list({
567
+ fields: {
568
+ name: text(),
569
+ childrenA: relationship({ ref: 'Node', many: true }),
570
+ childrenB: relationship({ ref: 'Node', many: true }),
571
+ },
572
+ }),
573
+ Extra1: list({ fields: { name: text() } }),
574
+ Extra2: list({ fields: { name: text() } }),
575
+ },
576
+ })
577
+
578
+ // A payload entry that must NEVER be walked once the walk has saturated —
579
+ // reading its `childrenA` property throws, so any attempt to descend
580
+ // into it fails the test with a thrown error instead of relying on timing.
581
+ const trap: Record<string, unknown> = { name: 'trap' }
582
+ Object.defineProperty(trap, 'childrenA', {
583
+ enumerable: true,
584
+ get(): never {
585
+ throw new Error('walkNested must not descend past the saturation bound')
586
+ },
587
+ })
588
+
589
+ const involved = enumerateInvolvedLists({
590
+ listName: 'Node',
591
+ listConfig: resolvedConfig.lists.Node,
592
+ operation: 'create',
593
+ inputData: {
594
+ name: 'root',
595
+ // Completes the saturation bound: seed (Node:create) + Node:update +
596
+ // Node:delete = 3 pairs = the full reachable closure for Node.
597
+ childrenA: {
598
+ update: [{ where: { id: 'u1' }, data: { name: 'updated' } }],
599
+ delete: [{ id: 'd1' }],
600
+ },
601
+ // Processed after childrenA (insertion order) — by the time the walk
602
+ // reaches it, the bound is already saturated, so `trap` must never
603
+ // be descended into.
604
+ childrenB: { create: [trap] },
605
+ },
606
+ topLevelOriginalItem: undefined,
607
+ config: resolvedConfig,
608
+ })
609
+
610
+ expect(involved.map((i) => `${i.listKey}:${i.operation}`)).toEqual([
611
+ 'Node:create',
612
+ 'Node:update',
613
+ 'Node:delete',
614
+ ])
615
+ })
616
+ })
617
+
618
+ /**
619
+ * #835 integration: the enumeration fix must not change the (separately
620
+ * verified, and unrelated) fact that nested writes stay access-checked at
621
+ * every depth — `processNestedOperations`'s own depth guard was already dead
622
+ * code before this fix (neither recursive call site nor the Write Pipeline
623
+ * ever passed a depth), so nested access control was never gated by depth and
624
+ * remains that way.
625
+ */
626
+ describe('#835 deep nested writes remain access-checked at every depth', () => {
627
+ function chainLists(length: number, denyCreateAt?: number) {
628
+ const lists: Record<string, ReturnType<typeof list>> = {}
629
+ for (let i = 1; i <= length; i++) {
630
+ const name = `L${i}`
631
+ const fields: Record<string, ReturnType<typeof text> | ReturnType<typeof relationship>> = {
632
+ name: text(),
633
+ }
634
+ if (i < length) {
635
+ fields[`l${i + 1}`] = relationship({ ref: `L${i + 1}` })
636
+ }
637
+ lists[name] = list({
638
+ fields,
639
+ access: {
640
+ operation: {
641
+ query: () => true,
642
+ create: () => denyCreateAt !== i,
643
+ update: () => true,
644
+ },
645
+ },
646
+ })
647
+ }
648
+ return lists
649
+ }
650
+
651
+ function chainInputData(length: number): Record<string, unknown> {
652
+ let payload: Record<string, unknown> = { name: `r${length}` }
653
+ for (let i = length - 1; i >= 1; i--) {
654
+ payload = { name: `r${i}`, [`l${i + 1}`]: { create: payload } }
655
+ }
656
+ return payload
657
+ }
658
+
659
+ it('throws when a nested create 6 levels deep is denied, even though 6 is past the old enumeration depth cap', async () => {
660
+ const tables = Array.from({ length: 8 }, (_, i) => `l${i + 1}`)
661
+ const mock = createTxPrisma(tables)
662
+
663
+ const testConfig = config({
664
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
665
+ lists: chainLists(8, 6),
666
+ })
667
+
668
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
669
+
670
+ await expect(context.db.l1.create({ data: chainInputData(8) })).rejects.toThrow(
671
+ /access denied/i,
672
+ )
673
+
674
+ // Nothing was persisted — the whole write was aborted by the denial.
675
+ for (const table of tables) {
676
+ expect(mock.tables[table].size).toBe(0)
677
+ }
678
+ })
679
+
680
+ it('fires beforeTransaction/afterTransaction for every list in an 8-list chain, including the deepest', async () => {
681
+ const tables = Array.from({ length: 8 }, (_, i) => `l${i + 1}`)
682
+ const mock = createTxPrisma(tables)
683
+
684
+ const fired: string[] = []
685
+ const lists = chainLists(8)
686
+ for (const [name, listConfig] of Object.entries(lists)) {
687
+ listConfig.hooks = {
688
+ beforeTransaction: () => {
689
+ fired.push(`before:${name}`)
690
+ },
691
+ afterTransaction: () => {
692
+ fired.push(`after:${name}`)
693
+ },
694
+ }
695
+ }
696
+
697
+ const testConfig = config({
698
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
699
+ lists,
700
+ })
701
+
702
+ const context = getContext(await testConfig, mock.client, { userId: '1' })
703
+ await context.db.l1.create({ data: chainInputData(8) })
704
+
705
+ for (let i = 1; i <= 8; i++) {
706
+ expect(fired).toContain(`before:L${i}`)
707
+ expect(fired).toContain(`after:L${i}`)
708
+ }
709
+ })
710
+ })