@opensaas/stack-core 0.39.2 → 0.41.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -307,6 +307,29 @@ describe('Field Types', () => {
|
|
|
307
307
|
expect(prismaType.type).toBe('Int')
|
|
308
308
|
expect(prismaType.modifiers).toBe('@db.BigInt')
|
|
309
309
|
})
|
|
310
|
+
|
|
311
|
+
test('isIndexed: true requests a block-level index', () => {
|
|
312
|
+
const field = integer({ isIndexed: true })
|
|
313
|
+
const prismaType = field.getPrismaType('rank')
|
|
314
|
+
|
|
315
|
+
expect(prismaType.index).toBe(true)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test("isIndexed: 'unique' generates @unique modifier", () => {
|
|
319
|
+
const field = integer({ isIndexed: 'unique' })
|
|
320
|
+
const prismaType = field.getPrismaType('rank')
|
|
321
|
+
|
|
322
|
+
expect(prismaType.modifiers).toContain('@unique')
|
|
323
|
+
expect(prismaType.index).toBeUndefined()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('no isIndexed generates neither modifier nor index', () => {
|
|
327
|
+
const field = integer()
|
|
328
|
+
const prismaType = field.getPrismaType('rank')
|
|
329
|
+
|
|
330
|
+
expect(prismaType.modifiers).not.toContain('@unique')
|
|
331
|
+
expect(prismaType.index).toBeUndefined()
|
|
332
|
+
})
|
|
310
333
|
})
|
|
311
334
|
|
|
312
335
|
describe('getTypeScriptType', () => {
|
|
@@ -679,6 +702,29 @@ describe('Field Types', () => {
|
|
|
679
702
|
expect(prismaType.type).toBe('DateTime')
|
|
680
703
|
expect(prismaType.modifiers).toContain('@db.Timestamptz')
|
|
681
704
|
})
|
|
705
|
+
|
|
706
|
+
test('isIndexed: true requests a block-level index', () => {
|
|
707
|
+
const field = timestamp({ isIndexed: true })
|
|
708
|
+
const prismaType = field.getPrismaType('publishedAt')
|
|
709
|
+
|
|
710
|
+
expect(prismaType.index).toBe(true)
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
test("isIndexed: 'unique' generates @unique modifier", () => {
|
|
714
|
+
const field = timestamp({ isIndexed: 'unique' })
|
|
715
|
+
const prismaType = field.getPrismaType('publishedAt')
|
|
716
|
+
|
|
717
|
+
expect(prismaType.modifiers).toContain('@unique')
|
|
718
|
+
expect(prismaType.index).toBeUndefined()
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
test('no isIndexed generates neither modifier nor index', () => {
|
|
722
|
+
const field = timestamp()
|
|
723
|
+
const prismaType = field.getPrismaType('publishedAt')
|
|
724
|
+
|
|
725
|
+
expect(prismaType.modifiers).not.toContain('@unique')
|
|
726
|
+
expect(prismaType.index).toBeUndefined()
|
|
727
|
+
})
|
|
682
728
|
})
|
|
683
729
|
|
|
684
730
|
describe('getTypeScriptType', () => {
|
|
@@ -810,6 +856,27 @@ describe('Field Types', () => {
|
|
|
810
856
|
)
|
|
811
857
|
})
|
|
812
858
|
})
|
|
859
|
+
|
|
860
|
+
describe('ui.listView.defaultColumn (issue #1018)', () => {
|
|
861
|
+
test('is excluded from default admin table columns by default', () => {
|
|
862
|
+
const field = password()
|
|
863
|
+
|
|
864
|
+
expect(field.ui?.listView?.defaultColumn).toBe(false)
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
test('can be opted back into default columns explicitly', () => {
|
|
868
|
+
const field = password({ ui: { listView: { defaultColumn: true } } })
|
|
869
|
+
|
|
870
|
+
expect(field.ui?.listView?.defaultColumn).toBe(true)
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
test('preserves other caller-supplied ui options', () => {
|
|
874
|
+
const field = password({ ui: { description: 'Account password' } })
|
|
875
|
+
|
|
876
|
+
expect(field.ui?.description).toBe('Account password')
|
|
877
|
+
expect(field.ui?.listView?.defaultColumn).toBe(false)
|
|
878
|
+
})
|
|
879
|
+
})
|
|
813
880
|
})
|
|
814
881
|
|
|
815
882
|
describe('select field', () => {
|
|
@@ -905,6 +972,52 @@ describe('Field Types', () => {
|
|
|
905
972
|
expect(prismaType.type).toBe('String')
|
|
906
973
|
expect(prismaType.modifiers).toBe(' @default("draft")')
|
|
907
974
|
})
|
|
975
|
+
|
|
976
|
+
test('isIndexed: true requests a block-level index on the default string column', () => {
|
|
977
|
+
const field = select({
|
|
978
|
+
options: [{ label: 'Draft', value: 'draft' }],
|
|
979
|
+
isIndexed: true,
|
|
980
|
+
})
|
|
981
|
+
const prismaType = field.getPrismaType('status')
|
|
982
|
+
|
|
983
|
+
expect(prismaType.type).toBe('String')
|
|
984
|
+
expect(prismaType.index).toBe(true)
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
test("isIndexed: 'unique' generates @unique on the default string column", () => {
|
|
988
|
+
const field = select({
|
|
989
|
+
options: [{ label: 'Draft', value: 'draft' }],
|
|
990
|
+
isIndexed: 'unique',
|
|
991
|
+
})
|
|
992
|
+
const prismaType = field.getPrismaType('status')
|
|
993
|
+
|
|
994
|
+
expect(prismaType.modifiers).toContain('@unique')
|
|
995
|
+
expect(prismaType.index).toBeUndefined()
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
test('isIndexed: true requests a block-level index on a native-enum column', () => {
|
|
999
|
+
const field = select({
|
|
1000
|
+
options: [{ label: 'Draft', value: 'draft' }],
|
|
1001
|
+
db: { type: 'enum' },
|
|
1002
|
+
isIndexed: true,
|
|
1003
|
+
})
|
|
1004
|
+
const prismaType = field.getPrismaType('status', undefined, 'Post')
|
|
1005
|
+
|
|
1006
|
+
expect(prismaType.type).toBe('PostStatus')
|
|
1007
|
+
expect(prismaType.index).toBe(true)
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
test("isIndexed: 'unique' generates @unique on a native-enum column", () => {
|
|
1011
|
+
const field = select({
|
|
1012
|
+
options: [{ label: 'Draft', value: 'draft' }],
|
|
1013
|
+
db: { type: 'enum' },
|
|
1014
|
+
isIndexed: 'unique',
|
|
1015
|
+
})
|
|
1016
|
+
const prismaType = field.getPrismaType('status', undefined, 'Post')
|
|
1017
|
+
|
|
1018
|
+
expect(prismaType.modifiers).toContain('@unique')
|
|
1019
|
+
expect(prismaType.index).toBeUndefined()
|
|
1020
|
+
})
|
|
908
1021
|
})
|
|
909
1022
|
|
|
910
1023
|
describe('getTypeScriptType', () => {
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text } from '../src/fields/index.js'
|
|
5
|
+
import { ResolveOutputCycleError } from '../src/access/index.js'
|
|
6
|
+
import type { Plugin } from '../src/config/types.js'
|
|
7
|
+
import type { StackContext } from '../src/context/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #1176: a list/field `resolveInput`/`validate`/`beforeOperation`/
|
|
11
|
+
* `afterOperation` hook now receives the FULL secured {@link StackContext} —
|
|
12
|
+
* `sudo()`, `withSession()`, `transaction()`, `serverAction` — bound to the
|
|
13
|
+
* write's OWN transaction client, instead of the bare `AccessContext` object
|
|
14
|
+
* literal `bindContextToTransaction` used to hand-assemble. See ADR-0012's
|
|
15
|
+
* amendment and `packages/core/CLAUDE.md`.
|
|
16
|
+
*
|
|
17
|
+
* `beforeTransaction`/`afterTransaction` are OUT of scope (ADR-0028): they
|
|
18
|
+
* keep a base-client `AccessContext`, unchanged by this issue.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A transaction-aware mock whose `$transaction` hands the callback a `tx`
|
|
23
|
+
* object that is NOT `client` itself (mirroring real Prisma, and required to
|
|
24
|
+
* prove a hook's context is bound to the TRANSACTION client, not the base
|
|
25
|
+
* one) and — faithfully — has no `$transaction` of its own, so a nested
|
|
26
|
+
* `context.transaction()`/`context.db` write can only join, never nest.
|
|
27
|
+
*/
|
|
28
|
+
function createTxPrisma(tableNames: string[]) {
|
|
29
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
30
|
+
for (const name of tableNames) tables[name] = new Map()
|
|
31
|
+
let idCounter = 0
|
|
32
|
+
const nextId = () => `id-${++idCounter}`
|
|
33
|
+
|
|
34
|
+
function makeModel(table: string) {
|
|
35
|
+
return {
|
|
36
|
+
findUnique: vi.fn(
|
|
37
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
38
|
+
),
|
|
39
|
+
findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
|
|
40
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
41
|
+
count: vi.fn(async () => tables[table].size),
|
|
42
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
43
|
+
const id = (data.id as string) ?? nextId()
|
|
44
|
+
const record = { ...data, id }
|
|
45
|
+
tables[table].set(id, record)
|
|
46
|
+
return record
|
|
47
|
+
}),
|
|
48
|
+
update: vi.fn(
|
|
49
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
50
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
51
|
+
const updated = { ...existing, ...data }
|
|
52
|
+
tables[table].set(where.id, updated)
|
|
53
|
+
return updated
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
57
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
58
|
+
tables[table].delete(where.id)
|
|
59
|
+
return existing
|
|
60
|
+
}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const client: Record<string, unknown> = {}
|
|
65
|
+
for (const name of tableNames) client[name] = makeModel(name)
|
|
66
|
+
|
|
67
|
+
const transactionSpy = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
68
|
+
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
69
|
+
for (const [name, map] of Object.entries(tables)) snapshot[name] = new Map(map)
|
|
70
|
+
// Faithful to real Prisma: the tx client has the model delegates but NOT
|
|
71
|
+
// `$transaction` — a nested write detects this and joins rather than
|
|
72
|
+
// opening a second transaction. Spreading into a NEW object also makes
|
|
73
|
+
// `tx !== client`, which the "bound to tx, not the base client" tests key on.
|
|
74
|
+
const { $transaction: _omit, ...models } = client
|
|
75
|
+
void _omit
|
|
76
|
+
try {
|
|
77
|
+
return await fn(models)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
for (const [name, map] of Object.entries(snapshot)) tables[name] = map
|
|
80
|
+
throw err
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
client.$transaction = transactionSpy
|
|
84
|
+
|
|
85
|
+
return { client, tables, transactionSpy }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('#1176 hook context is a full secured StackContext bound to the transaction', () => {
|
|
89
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mock = createTxPrisma(['user'])
|
|
93
|
+
vi.clearAllMocks()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('resolveInput/validate/beforeOperation/afterOperation (list AND field) all receive sudo/withSession/transaction/serverAction, bound to `tx`', async () => {
|
|
97
|
+
const captured: StackContext[] = []
|
|
98
|
+
|
|
99
|
+
const testConfig = await config({
|
|
100
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
101
|
+
lists: {
|
|
102
|
+
User: list({
|
|
103
|
+
fields: {
|
|
104
|
+
name: text({
|
|
105
|
+
hooks: {
|
|
106
|
+
resolveInput: async ({ context, resolvedData, fieldKey }) => {
|
|
107
|
+
captured.push(context)
|
|
108
|
+
return resolvedData[fieldKey]
|
|
109
|
+
},
|
|
110
|
+
validate: async ({ context }) => {
|
|
111
|
+
captured.push(context)
|
|
112
|
+
},
|
|
113
|
+
beforeOperation: async ({ context }) => {
|
|
114
|
+
captured.push(context)
|
|
115
|
+
},
|
|
116
|
+
afterOperation: async ({ context }) => {
|
|
117
|
+
captured.push(context)
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
123
|
+
hooks: {
|
|
124
|
+
resolveInput: async ({ context, resolvedData }) => {
|
|
125
|
+
captured.push(context)
|
|
126
|
+
return resolvedData
|
|
127
|
+
},
|
|
128
|
+
validate: async ({ context }) => {
|
|
129
|
+
captured.push(context)
|
|
130
|
+
},
|
|
131
|
+
beforeOperation: async ({ context }) => {
|
|
132
|
+
captured.push(context)
|
|
133
|
+
},
|
|
134
|
+
afterOperation: async ({ context }) => {
|
|
135
|
+
captured.push(context)
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
143
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
144
|
+
|
|
145
|
+
// list resolveInput/validate/beforeOperation/afterOperation + the same 4
|
|
146
|
+
// at field level = 8 hook invocations, one create.
|
|
147
|
+
expect(captured).toHaveLength(8)
|
|
148
|
+
|
|
149
|
+
for (const hookContext of captured) {
|
|
150
|
+
expect(typeof hookContext.sudo).toBe('function')
|
|
151
|
+
expect(typeof hookContext.withSession).toBe('function')
|
|
152
|
+
expect(typeof hookContext.transaction).toBe('function')
|
|
153
|
+
expect(typeof hookContext.serverAction).toBe('function')
|
|
154
|
+
// Bound to the transaction client, never the base one.
|
|
155
|
+
expect(hookContext.prisma).not.toBe(mock.client)
|
|
156
|
+
expect(hookContext.sudo().prisma).toBe(hookContext.prisma)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('a context.sudo()/.withSession() write from beforeOperation succeeds against an access-denied-for-the-session list, and rolls back with the outer write', async () => {
|
|
161
|
+
async function run(shouldThrow: boolean) {
|
|
162
|
+
const denyMock = createTxPrisma(['user', 'draft'])
|
|
163
|
+
const testConfig = await config({
|
|
164
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
165
|
+
lists: {
|
|
166
|
+
User: list({
|
|
167
|
+
fields: { name: text() },
|
|
168
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
169
|
+
hooks: {
|
|
170
|
+
beforeOperation: async ({ context, operation }) => {
|
|
171
|
+
if (operation !== 'create') return
|
|
172
|
+
// Denied for this session under normal access — only sudo can write it.
|
|
173
|
+
await context.sudo().db.draft.create({ data: { note: 'draft' } })
|
|
174
|
+
if (shouldThrow) {
|
|
175
|
+
throw new Error('boom')
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
Draft: list({
|
|
181
|
+
fields: { note: text() },
|
|
182
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
const context = getContext(testConfig, denyMock.client, { userId: '1' })
|
|
187
|
+
|
|
188
|
+
if (shouldThrow) {
|
|
189
|
+
await expect(context.db.user.create({ data: { name: 'jane' } })).rejects.toThrow('boom')
|
|
190
|
+
} else {
|
|
191
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
192
|
+
}
|
|
193
|
+
return denyMock
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const committed = await run(false)
|
|
197
|
+
expect(committed.tables.draft.size).toBe(1)
|
|
198
|
+
|
|
199
|
+
const rolledBack = await run(true)
|
|
200
|
+
expect(rolledBack.tables.draft.size).toBe(0)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('context.withSession() from a hook stays bound to `tx` and preserves the receiver sudo state; sudo()/withSession() compose in either order', async () => {
|
|
204
|
+
const seen: { withSessionPrisma?: unknown; ownPrisma?: unknown; combos?: boolean[] } = {}
|
|
205
|
+
|
|
206
|
+
const testConfig = await config({
|
|
207
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
208
|
+
lists: {
|
|
209
|
+
User: list({
|
|
210
|
+
fields: { name: text() },
|
|
211
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
212
|
+
hooks: {
|
|
213
|
+
beforeOperation: async ({ context }) => {
|
|
214
|
+
seen.ownPrisma = context.prisma
|
|
215
|
+
seen.withSessionPrisma = context.withSession({ userId: '2' }).prisma
|
|
216
|
+
seen.combos = [
|
|
217
|
+
context.sudo().withSession({ userId: '2' })._isSudo,
|
|
218
|
+
context.withSession({ userId: '2' }).sudo()._isSudo,
|
|
219
|
+
context.withSession({ userId: '2' })._isSudo, // non-sudo receiver stays non-sudo
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const context = getContext(testConfig, mock.client, { userId: '1' })
|
|
228
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
229
|
+
|
|
230
|
+
expect(seen.withSessionPrisma).toBe(seen.ownPrisma)
|
|
231
|
+
expect(seen.combos).toEqual([true, true, false])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('context.transaction() called from a hook JOINS the write transaction rather than opening a nested one', async () => {
|
|
235
|
+
const joinMock = createTxPrisma(['user', 'note'])
|
|
236
|
+
const testConfig = await config({
|
|
237
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
238
|
+
lists: {
|
|
239
|
+
User: list({
|
|
240
|
+
fields: { name: text() },
|
|
241
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
242
|
+
hooks: {
|
|
243
|
+
beforeOperation: async ({ context }) => {
|
|
244
|
+
await context.transaction(async (tx2) => {
|
|
245
|
+
await tx2.db.note.create({ data: { name: 'joined' } })
|
|
246
|
+
})
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
Note: list({
|
|
251
|
+
fields: { name: text() },
|
|
252
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const context = getContext(testConfig, joinMock.client, { userId: '1' })
|
|
258
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
259
|
+
|
|
260
|
+
// Exactly the one outer transaction — the hook's `context.transaction()`
|
|
261
|
+
// joined it instead of calling `$transaction` again.
|
|
262
|
+
expect(joinMock.transactionSpy).toHaveBeenCalledTimes(1)
|
|
263
|
+
expect(joinMock.tables.user.size).toBe(1)
|
|
264
|
+
expect(joinMock.tables.note.size).toBe(1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('a write issued through a hook context.sudo() defers afterTransaction to the outer settle and reports the outer outcome', async () => {
|
|
268
|
+
async function run(commentThrows: boolean) {
|
|
269
|
+
const m = createTxPrisma(['user', 'comment', 'audit'])
|
|
270
|
+
const auditAfter = vi.fn()
|
|
271
|
+
const testConfig = await config({
|
|
272
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
273
|
+
lists: {
|
|
274
|
+
User: list({
|
|
275
|
+
fields: { name: text() },
|
|
276
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
277
|
+
hooks: {
|
|
278
|
+
afterOperation: async ({ context, operation }) => {
|
|
279
|
+
if (operation === 'create') {
|
|
280
|
+
await context.sudo().db.audit.create({ data: { note: 'audit' } })
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
Comment: list({
|
|
286
|
+
fields: { body: text() },
|
|
287
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
288
|
+
hooks: commentThrows
|
|
289
|
+
? {
|
|
290
|
+
afterOperation: () => {
|
|
291
|
+
throw new Error('comment boom')
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
: undefined,
|
|
295
|
+
}),
|
|
296
|
+
// Denied for the session — only reachable via sudo, proving the
|
|
297
|
+
// deferred afterTransaction reports the join even for a sudo write.
|
|
298
|
+
Audit: list({
|
|
299
|
+
fields: { note: text() },
|
|
300
|
+
access: { operation: { query: () => true, create: () => false } },
|
|
301
|
+
hooks: { afterTransaction: auditAfter },
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
const context = getContext(testConfig, m.client, { userId: '1' })
|
|
306
|
+
|
|
307
|
+
if (commentThrows) {
|
|
308
|
+
await expect(
|
|
309
|
+
context.transaction(async (tx) => {
|
|
310
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
311
|
+
await tx.db.comment.create({ data: { body: 'trigger' } })
|
|
312
|
+
}),
|
|
313
|
+
).rejects.toThrow('comment boom')
|
|
314
|
+
} else {
|
|
315
|
+
await context.transaction(async (tx) => {
|
|
316
|
+
await tx.db.user.create({ data: { name: 'jane' } })
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { m, auditAfter }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const committed = await run(false)
|
|
324
|
+
expect(committed.m.tables.audit.size).toBe(1)
|
|
325
|
+
expect(committed.auditAfter).toHaveBeenCalledTimes(1)
|
|
326
|
+
expect(committed.auditAfter.mock.calls[0][0].status).toBe('committed')
|
|
327
|
+
|
|
328
|
+
const rolledBack = await run(true)
|
|
329
|
+
expect(rolledBack.m.tables.audit.size).toBe(0)
|
|
330
|
+
expect(rolledBack.auditAfter).toHaveBeenCalledTimes(1)
|
|
331
|
+
expect(rolledBack.auditAfter.mock.calls[0][0].status).toBe('rolled-back')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('plugin runtime() is invoked exactly once per request, even across a hook-firing write with a nested context.db write', async () => {
|
|
335
|
+
const runtimeSpy = vi.fn(() => ({}))
|
|
336
|
+
const testPlugin: Plugin = {
|
|
337
|
+
name: 'test-plugin',
|
|
338
|
+
init: () => {},
|
|
339
|
+
runtime: runtimeSpy,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const nestedMock = createTxPrisma(['user', 'audit'])
|
|
343
|
+
const testConfig = await config({
|
|
344
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
345
|
+
plugins: [testPlugin],
|
|
346
|
+
lists: {
|
|
347
|
+
User: list({
|
|
348
|
+
fields: { name: text() },
|
|
349
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
350
|
+
hooks: {
|
|
351
|
+
afterOperation: async ({ context, operation }) => {
|
|
352
|
+
if (operation === 'create') {
|
|
353
|
+
await context.db.audit.create({ data: { note: 'audit' } })
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
Audit: list({
|
|
359
|
+
fields: { note: text() },
|
|
360
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const context = getContext(testConfig, nestedMock.client, { userId: '1' })
|
|
366
|
+
await context.db.user.create({ data: { name: 'jane' } })
|
|
367
|
+
|
|
368
|
+
expect(runtimeSpy).toHaveBeenCalledTimes(1)
|
|
369
|
+
expect(nestedMock.tables.audit.size).toBe(1)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it("a write issued from inside a resolveOutput hook carries that hook's resolve chain into the write's own Field Visibility pass (ADR-0023)", async () => {
|
|
373
|
+
// Ping.label's resolveOutput creates a Pong row, whose own Field
|
|
374
|
+
// Visibility pass (Phase 11 of THAT write) computes Pong.label — which
|
|
375
|
+
// creates a Ping row, whose Field Visibility pass computes Ping.label
|
|
376
|
+
// again. If (and only if) the write's hook context carries the resolve
|
|
377
|
+
// chain forward, that third hop re-enters `Ping.label`, already on the
|
|
378
|
+
// chain, and the cycle guard throws instead of recursing forever.
|
|
379
|
+
const { virtual } = await import('../src/fields/index.js')
|
|
380
|
+
|
|
381
|
+
const cycleMock = createTxPrisma(['ping', 'pong'])
|
|
382
|
+
const testConfig = await config({
|
|
383
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
384
|
+
lists: {
|
|
385
|
+
Ping: list({
|
|
386
|
+
fields: {
|
|
387
|
+
name: text(),
|
|
388
|
+
label: virtual({
|
|
389
|
+
type: 'string',
|
|
390
|
+
hooks: {
|
|
391
|
+
resolveOutput: async ({ context }) => {
|
|
392
|
+
await context.db.pong.create({ data: { name: 'q' } })
|
|
393
|
+
return 'ping'
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
},
|
|
398
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
399
|
+
}),
|
|
400
|
+
Pong: list({
|
|
401
|
+
fields: {
|
|
402
|
+
name: text(),
|
|
403
|
+
label: virtual({
|
|
404
|
+
type: 'string',
|
|
405
|
+
hooks: {
|
|
406
|
+
resolveOutput: async ({ context }) => {
|
|
407
|
+
await context.db.ping.create({ data: { name: 'p' } })
|
|
408
|
+
return 'pong'
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
413
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
414
|
+
}),
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
const context = getContext(testConfig, cycleMock.client, { userId: '1' })
|
|
419
|
+
|
|
420
|
+
await expect(context.db.ping.create({ data: { name: 'p0' } })).rejects.toThrow(
|
|
421
|
+
ResolveOutputCycleError,
|
|
422
|
+
)
|
|
423
|
+
})
|
|
424
|
+
})
|