@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
package/tests/context.test.ts
CHANGED
|
@@ -48,6 +48,7 @@ describe('getContext', () => {
|
|
|
48
48
|
fields: {
|
|
49
49
|
name: { type: 'text' },
|
|
50
50
|
email: { type: 'text', isIndexed: 'unique' },
|
|
51
|
+
posts: { type: 'relationship', ref: 'Post.author', many: true },
|
|
51
52
|
},
|
|
52
53
|
access: {
|
|
53
54
|
operation: {
|
|
@@ -895,6 +896,224 @@ describe('getContext', () => {
|
|
|
895
896
|
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
896
897
|
expect(created.fieldErrors?.title).toBeDefined()
|
|
897
898
|
})
|
|
899
|
+
|
|
900
|
+
// Prisma 7 driver adapters (verified against @prisma/adapter-pg and PGlite,
|
|
901
|
+
// issue #979) leave `meta.target` undefined and put the equivalent data at
|
|
902
|
+
// `meta.driverAdapterError.cause` instead, as free-text-adjacent structured
|
|
903
|
+
// data rather than the documented shape.
|
|
904
|
+
describe('P2002 under Prisma 7 driver adapters (issue #979)', () => {
|
|
905
|
+
it('produces per-field errors for a composite unique violation', async () => {
|
|
906
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
907
|
+
code: 'P2002',
|
|
908
|
+
meta: {
|
|
909
|
+
modelName: 'Post',
|
|
910
|
+
driverAdapterError: {
|
|
911
|
+
name: 'DriverAdapterError',
|
|
912
|
+
cause: {
|
|
913
|
+
originalCode: '23505',
|
|
914
|
+
originalMessage:
|
|
915
|
+
'duplicate key value violates unique constraint "post_title_content_key"',
|
|
916
|
+
kind: 'UniqueConstraintViolation',
|
|
917
|
+
constraint: { fields: ['title', 'content'] },
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
})
|
|
922
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
923
|
+
|
|
924
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
925
|
+
const result = await context.serverAction({
|
|
926
|
+
listKey: 'Post',
|
|
927
|
+
action: 'createRelated',
|
|
928
|
+
data: { title: 'Dup', content: 'Dup' },
|
|
929
|
+
field: 'author',
|
|
930
|
+
parentId: 'u1',
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
expect(result).toMatchObject({ created: false })
|
|
934
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
935
|
+
expect(created.fieldErrors?.title).toBeDefined()
|
|
936
|
+
expect(created.fieldErrors?.content).toBeDefined()
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
it('produces a field error for a single-column unique violation', async () => {
|
|
940
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
941
|
+
code: 'P2002',
|
|
942
|
+
meta: {
|
|
943
|
+
modelName: 'Post',
|
|
944
|
+
driverAdapterError: {
|
|
945
|
+
cause: {
|
|
946
|
+
originalMessage:
|
|
947
|
+
'duplicate key value violates unique constraint "post_title_key"',
|
|
948
|
+
constraint: { fields: ['title'] },
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
})
|
|
953
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
954
|
+
|
|
955
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
956
|
+
const result = await context.serverAction({
|
|
957
|
+
listKey: 'Post',
|
|
958
|
+
action: 'createRelated',
|
|
959
|
+
data: { title: 'Dup' },
|
|
960
|
+
field: 'author',
|
|
961
|
+
parentId: 'u1',
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
965
|
+
expect(created.fieldErrors?.title).toBeDefined()
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
it('strips quotes so a camelCase column is keyed correctly (not left quoted)', async () => {
|
|
969
|
+
// Postgres quotes an identifier in `constraint.fields` only when it
|
|
970
|
+
// needed quoting — a camelCase column arrives as `"authorId"`. A naive
|
|
971
|
+
// fix that skips stripping would key fieldErrors by the literal string
|
|
972
|
+
// `"authorId"` (quotes included), missing the real field name.
|
|
973
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
974
|
+
code: 'P2002',
|
|
975
|
+
meta: {
|
|
976
|
+
driverAdapterError: {
|
|
977
|
+
cause: {
|
|
978
|
+
originalMessage:
|
|
979
|
+
'duplicate key value violates unique constraint "post_authorId_key"',
|
|
980
|
+
constraint: { fields: ['"authorId"'] },
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
})
|
|
985
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
986
|
+
|
|
987
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
988
|
+
const result = await context.serverAction({
|
|
989
|
+
listKey: 'Post',
|
|
990
|
+
action: 'createRelated',
|
|
991
|
+
data: { title: 'Dup' },
|
|
992
|
+
field: 'author',
|
|
993
|
+
parentId: 'u1',
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
997
|
+
expect(created.fieldErrors).toEqual({ authorId: 'This value is already in use' })
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it('humanizes a camelCase field name in the message instead of running words together', async () => {
|
|
1001
|
+
// Before this fix, `target` was always empty under a driver adapter, so
|
|
1002
|
+
// this label-formatting path never ran for a camelCase column. Recovering
|
|
1003
|
+
// real column names makes it reachable — verify it reads as words, not
|
|
1004
|
+
// "tenantid".
|
|
1005
|
+
const camelCaseConfig: OpenSaasConfig = {
|
|
1006
|
+
...config,
|
|
1007
|
+
lists: {
|
|
1008
|
+
...config.lists,
|
|
1009
|
+
Post: {
|
|
1010
|
+
...config.lists.Post,
|
|
1011
|
+
fields: { ...config.lists.Post.fields, tenantSlug: { type: 'text' } },
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
}
|
|
1015
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
1016
|
+
code: 'P2002',
|
|
1017
|
+
meta: {
|
|
1018
|
+
driverAdapterError: {
|
|
1019
|
+
cause: {
|
|
1020
|
+
originalMessage:
|
|
1021
|
+
'duplicate key value violates unique constraint "post_tenantSlug_key"',
|
|
1022
|
+
constraint: { fields: ['"tenantSlug"'] },
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
})
|
|
1027
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
1028
|
+
|
|
1029
|
+
const context = await getContext(camelCaseConfig, mockPrisma, { userId: 'u1' })
|
|
1030
|
+
const result = await context.serverAction({
|
|
1031
|
+
listKey: 'Post',
|
|
1032
|
+
action: 'createRelated',
|
|
1033
|
+
data: { title: 'Dup' },
|
|
1034
|
+
field: 'author',
|
|
1035
|
+
parentId: 'u1',
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
1039
|
+
expect(created.fieldErrors).toEqual({
|
|
1040
|
+
tenantSlug: 'This tenant slug is already in use',
|
|
1041
|
+
})
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
it('leaves an already-populated meta.target unaffected (existing path still wins)', async () => {
|
|
1045
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
1046
|
+
code: 'P2002',
|
|
1047
|
+
meta: {
|
|
1048
|
+
target: ['title'],
|
|
1049
|
+
// Present but must be ignored — meta.target already answers the question.
|
|
1050
|
+
driverAdapterError: {
|
|
1051
|
+
cause: {
|
|
1052
|
+
originalMessage: 'duplicate key value violates unique constraint "other_key"',
|
|
1053
|
+
constraint: { fields: ['content'] },
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
})
|
|
1058
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
1059
|
+
|
|
1060
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
1061
|
+
const result = await context.serverAction({
|
|
1062
|
+
listKey: 'Post',
|
|
1063
|
+
action: 'createRelated',
|
|
1064
|
+
data: { title: 'Dup' },
|
|
1065
|
+
field: 'author',
|
|
1066
|
+
parentId: 'u1',
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
1070
|
+
expect(created.fieldErrors).toEqual({ title: 'This title is already in use' })
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
it('falls back to the generic message (not a throw) when nothing is recoverable', async () => {
|
|
1074
|
+
mockPrisma.post.create.mockRejectedValue({ code: 'P2002', meta: {} })
|
|
1075
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
1076
|
+
|
|
1077
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
1078
|
+
const result = await context.serverAction({
|
|
1079
|
+
listKey: 'Post',
|
|
1080
|
+
action: 'createRelated',
|
|
1081
|
+
data: { title: 'Dup' },
|
|
1082
|
+
field: 'author',
|
|
1083
|
+
parentId: 'u1',
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
expect(result).toEqual({
|
|
1087
|
+
created: false,
|
|
1088
|
+
error: 'A record with this value already exists',
|
|
1089
|
+
fieldErrors: {},
|
|
1090
|
+
})
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
it('leaves a non-P2002 Prisma error unaffected', async () => {
|
|
1094
|
+
mockPrisma.post.create.mockRejectedValue({
|
|
1095
|
+
code: 'P2025',
|
|
1096
|
+
meta: { driverAdapterError: { cause: { constraint: { fields: ['title'] } } } },
|
|
1097
|
+
message: 'Record to update not found',
|
|
1098
|
+
})
|
|
1099
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
1100
|
+
|
|
1101
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
1102
|
+
const result = await context.serverAction({
|
|
1103
|
+
listKey: 'Post',
|
|
1104
|
+
action: 'createRelated',
|
|
1105
|
+
data: { title: 'Dup' },
|
|
1106
|
+
field: 'author',
|
|
1107
|
+
parentId: 'u1',
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
expect(result).toEqual({
|
|
1111
|
+
created: false,
|
|
1112
|
+
error: 'Record to update not found',
|
|
1113
|
+
fieldErrors: {},
|
|
1114
|
+
})
|
|
1115
|
+
})
|
|
1116
|
+
})
|
|
898
1117
|
})
|
|
899
1118
|
|
|
900
1119
|
describe('relationshipOptions', () => {
|
|
@@ -2063,6 +2282,59 @@ describe('getContext', () => {
|
|
|
2063
2282
|
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
2064
2283
|
})
|
|
2065
2284
|
|
|
2285
|
+
it('caller `_count` folds the relation access where into the select (#1087)', async () => {
|
|
2286
|
+
relPrisma.author.findMany.mockResolvedValue([
|
|
2287
|
+
{ id: 'a1', name: 'Jo', _count: { posts: 3 } },
|
|
2288
|
+
])
|
|
2289
|
+
|
|
2290
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2291
|
+
const result = await context.db.author.findMany({
|
|
2292
|
+
include: { _count: { select: { posts: true } } },
|
|
2293
|
+
})
|
|
2294
|
+
|
|
2295
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2296
|
+
expect(call.include._count).toEqual({
|
|
2297
|
+
select: { posts: { where: { status: { equals: 'published' } } } },
|
|
2298
|
+
})
|
|
2299
|
+
expect(result[0]._count).toEqual({ posts: 3 })
|
|
2300
|
+
})
|
|
2301
|
+
|
|
2302
|
+
it('caller `_count` on a fully-denied relation is omitted from the select and returns 0 (#1087)', async () => {
|
|
2303
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2304
|
+
|
|
2305
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2306
|
+
const result = await context.db.author.findMany({
|
|
2307
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2308
|
+
})
|
|
2309
|
+
|
|
2310
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2311
|
+
// The denied `Secret` list is never asked to be counted...
|
|
2312
|
+
expect(call.include._count).toEqual({
|
|
2313
|
+
select: { posts: { where: { status: { equals: 'published' } } } },
|
|
2314
|
+
})
|
|
2315
|
+
// ...but the caller still sees an explicit `0`, never an absent key —
|
|
2316
|
+
// a count is session-relative, and `0` is what "no visible rows" means.
|
|
2317
|
+
expect(result[0]._count).toEqual({ secrets: 0 })
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2320
|
+
it('sudo `_count` is used as-is, unscoped (behaviour preserved)', async () => {
|
|
2321
|
+
relPrisma.author.findMany.mockResolvedValue([
|
|
2322
|
+
{ id: 'a1', name: 'Jo', _count: { posts: 3, secrets: 7 } },
|
|
2323
|
+
])
|
|
2324
|
+
|
|
2325
|
+
const context = await getContext(relConfig, relPrisma, null).sudo()
|
|
2326
|
+
const result = await context.db.author.findMany({
|
|
2327
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2328
|
+
})
|
|
2329
|
+
|
|
2330
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith(
|
|
2331
|
+
expect.objectContaining({
|
|
2332
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2333
|
+
}),
|
|
2334
|
+
)
|
|
2335
|
+
expect(result[0]._count).toEqual({ posts: 3, secrets: 7 })
|
|
2336
|
+
})
|
|
2337
|
+
|
|
2066
2338
|
it('AND-combines a caller nested where with the relation access where', async () => {
|
|
2067
2339
|
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2068
2340
|
|
|
@@ -2104,7 +2376,7 @@ describe('getContext', () => {
|
|
|
2104
2376
|
)
|
|
2105
2377
|
})
|
|
2106
2378
|
|
|
2107
|
-
it('query fragment path
|
|
2379
|
+
it('query fragment path carries the access filter, same as the include: path (#1088)', async () => {
|
|
2108
2380
|
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2109
2381
|
|
|
2110
2382
|
const postsFragment = defineFragment<{ id: string; title: string }>()({
|
|
@@ -2120,10 +2392,144 @@ describe('getContext', () => {
|
|
|
2120
2392
|
await context.db.author.findMany({ query: fragment })
|
|
2121
2393
|
|
|
2122
2394
|
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
|
|
2395
|
+
// The fragment-built include now runs through the same scoping walk as
|
|
2396
|
+
// an explicit caller `include`, so `posts` carries Post's access where
|
|
2397
|
+
// (matching the `include: { posts: true }` test above) instead of a
|
|
2398
|
+
// bare, unfiltered `true`.
|
|
2399
|
+
expect(call.include).toEqual({ posts: { where: { status: { equals: 'published' } } } })
|
|
2400
|
+
})
|
|
2401
|
+
|
|
2402
|
+
it('drops a relation whose query access is false when named in a query fragment', async () => {
|
|
2403
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2404
|
+
|
|
2405
|
+
const secretsFragment = defineFragment<{ id: string; value: string }>()({
|
|
2406
|
+
value: true,
|
|
2407
|
+
} as const)
|
|
2408
|
+
const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
|
|
2409
|
+
id: true,
|
|
2410
|
+
name: true,
|
|
2411
|
+
secrets: secretsFragment,
|
|
2412
|
+
} as const)
|
|
2413
|
+
|
|
2414
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2415
|
+
await context.db.author.findMany({ query: fragment })
|
|
2416
|
+
|
|
2417
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2418
|
+
// `Secret`'s query access is `() => false` — the denied relation is
|
|
2419
|
+
// dropped from the include entirely, same as the `include:` path.
|
|
2420
|
+
expect(call.include.secrets).toBeUndefined()
|
|
2421
|
+
})
|
|
2422
|
+
|
|
2423
|
+
it('a fragment nesting past the depth cap raises the same depth error a caller include does', async () => {
|
|
2424
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 2
|
|
2425
|
+
const chainConfig: OpenSaasConfig = {
|
|
2426
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
2427
|
+
lists: {},
|
|
2428
|
+
}
|
|
2429
|
+
for (let i = 0; i < chainLength; i++) {
|
|
2430
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
2431
|
+
const fields: Record<string, any> = { name: { type: 'text' } }
|
|
2432
|
+
if (i < chainLength - 1) fields.next = { type: 'relationship', ref: `D${i + 1}.prev` }
|
|
2433
|
+
if (i > 0) fields.prev = { type: 'relationship', ref: `D${i - 1}.next` }
|
|
2434
|
+
chainConfig.lists[`D${i}`] = { fields, access: { operation: { query: () => true } } }
|
|
2435
|
+
}
|
|
2436
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2437
|
+
const chainPrisma: any = {}
|
|
2438
|
+
for (let i = 0; i < chainLength; i++) {
|
|
2439
|
+
chainPrisma[`d${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// A fragment selecting `next` recursively, `hops` levels deep.
|
|
2443
|
+
function nestedFragmentFields(hops: number): Record<string, unknown> {
|
|
2444
|
+
if (hops <= 0) return { name: true }
|
|
2445
|
+
return {
|
|
2446
|
+
name: true,
|
|
2447
|
+
next: defineFragment<Record<string, unknown>>()(nestedFragmentFields(hops - 1)),
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const deepFragment = defineFragment<Record<string, unknown>>()(
|
|
2451
|
+
nestedFragmentFields(READ_INCLUDE_MAX_DEPTH + 1),
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
const context = await getContext(chainConfig, chainPrisma, null)
|
|
2455
|
+
|
|
2456
|
+
await expect(context.db.d0.findMany({ query: deepFragment })).rejects.toThrow(
|
|
2457
|
+
AccessScopeDepthExceededError,
|
|
2458
|
+
)
|
|
2459
|
+
expect(chainPrisma.d0.findMany).not.toHaveBeenCalled()
|
|
2460
|
+
})
|
|
2461
|
+
|
|
2462
|
+
it('sudo query fragment reads stay unscoped (behaviour preserved)', async () => {
|
|
2463
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2464
|
+
|
|
2465
|
+
const secretsFragment = defineFragment<{ id: string; value: string }>()({
|
|
2466
|
+
value: true,
|
|
2467
|
+
} as const)
|
|
2468
|
+
const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
|
|
2469
|
+
id: true,
|
|
2470
|
+
name: true,
|
|
2471
|
+
secrets: secretsFragment,
|
|
2472
|
+
} as const)
|
|
2473
|
+
|
|
2474
|
+
const context = await getContext(relConfig, relPrisma, null).sudo()
|
|
2475
|
+
await context.db.author.findMany({ query: fragment })
|
|
2476
|
+
|
|
2477
|
+
// Under sudo, the fragment-built include is used as-is: the denied
|
|
2478
|
+
// `Secret` relation is fetched unfiltered, matching sudo's existing
|
|
2479
|
+
// include: behaviour.
|
|
2480
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith(
|
|
2481
|
+
expect.objectContaining({ include: { secrets: true } }),
|
|
2482
|
+
)
|
|
2483
|
+
})
|
|
2484
|
+
|
|
2485
|
+
it('a fragment read on a to-one relation nulls out a row its related list denies, matching include: (#974)', async () => {
|
|
2486
|
+
// `Comment.post` is a to-one relation onto `Post`, whose query access
|
|
2487
|
+
// is a row filter (`status: published`) rather than a plain boolean —
|
|
2488
|
+
// Prisma can't carry that as a `where` on a to-one include, so it's
|
|
2489
|
+
// resolved via the post-query existence check instead.
|
|
2490
|
+
const postFragment = defineFragment<{ id: string; title: string }>()({
|
|
2491
|
+
title: true,
|
|
2492
|
+
} as const)
|
|
2493
|
+
const fragment = defineFragment<{ id: string; body: string; post: unknown }>()({
|
|
2494
|
+
id: true,
|
|
2495
|
+
body: true,
|
|
2496
|
+
post: postFragment,
|
|
2497
|
+
} as const)
|
|
2498
|
+
|
|
2499
|
+
relPrisma.comment.findMany.mockResolvedValue([
|
|
2500
|
+
{ id: 'c1', body: 'hi', post: { id: 'p1', title: 'Draft' } },
|
|
2501
|
+
])
|
|
2502
|
+
// The batched existence check queries the raw Post model directly;
|
|
2503
|
+
// an empty result means `p1` does not satisfy Post's access filter.
|
|
2504
|
+
relPrisma.post.findMany.mockResolvedValue([])
|
|
2505
|
+
|
|
2506
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2507
|
+
const result = await context.db.comment.findMany({ query: fragment })
|
|
2508
|
+
|
|
2509
|
+
expect(result[0].post).toBeNull()
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
it('a fragment read and an equivalent include: read produce the same access-scoped include', async () => {
|
|
2513
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2514
|
+
|
|
2515
|
+
const postsFragment = defineFragment<{ id: string; title: string }>()({
|
|
2516
|
+
title: true,
|
|
2517
|
+
} as const)
|
|
2518
|
+
const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
|
|
2519
|
+
id: true,
|
|
2520
|
+
name: true,
|
|
2521
|
+
posts: postsFragment,
|
|
2522
|
+
} as const)
|
|
2523
|
+
|
|
2524
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2525
|
+
await context.db.author.findMany({ query: fragment })
|
|
2526
|
+
const fragmentInclude = relPrisma.author.findMany.mock.calls[0][0].include
|
|
2527
|
+
|
|
2528
|
+
relPrisma.author.findMany.mockClear()
|
|
2529
|
+
await context.db.author.findMany({ include: { posts: true } })
|
|
2530
|
+
const callerInclude = relPrisma.author.findMany.mock.calls[0][0].include
|
|
2531
|
+
|
|
2532
|
+
expect(fragmentInclude).toEqual(callerInclude)
|
|
2127
2533
|
})
|
|
2128
2534
|
|
|
2129
2535
|
// Core new guarantee introduced by #852 / ADR-0026: naming one relation
|
|
@@ -2194,7 +2600,7 @@ describe('getContext', () => {
|
|
|
2194
2600
|
}
|
|
2195
2601
|
}
|
|
2196
2602
|
|
|
2197
|
-
it('findUnique inside a resolveOutput hook
|
|
2603
|
+
it('findUnique inside a resolveOutput hook still fetches the relation, unscoped at the Prisma level (#974)', async () => {
|
|
2198
2604
|
let innerIncludeSeen: unknown
|
|
2199
2605
|
const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
|
|
2200
2606
|
innerIncludeSeen = include
|
|
@@ -2206,9 +2612,11 @@ describe('getContext', () => {
|
|
|
2206
2612
|
const context = await getContext(hookConfig, relPrisma, null)
|
|
2207
2613
|
await context.db.author.findUnique({ where: { id: 'a1' } })
|
|
2208
2614
|
|
|
2209
|
-
// `post` is still fetched (not dropped)
|
|
2210
|
-
//
|
|
2211
|
-
|
|
2615
|
+
// `post` is still fetched (not dropped). It carries no `where` — Post
|
|
2616
|
+
// is a to-one relation here, and Prisma rejects a `where` on a to-one
|
|
2617
|
+
// include (#974); Post's own access filter is enforced afterward via
|
|
2618
|
+
// a batched existence check instead (see access-filter.test.ts).
|
|
2619
|
+
expect(innerIncludeSeen).toEqual({ post: true })
|
|
2212
2620
|
})
|
|
2213
2621
|
|
|
2214
2622
|
it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
|
|
@@ -2227,10 +2635,10 @@ describe('getContext', () => {
|
|
|
2227
2635
|
await context.db.author.findMany()
|
|
2228
2636
|
|
|
2229
2637
|
// The caller's own nested selection (`post.include.author`) is honoured
|
|
2230
|
-
// as-is (access control does not auto-descend further here)
|
|
2231
|
-
// `
|
|
2638
|
+
// as-is (access control does not auto-descend further here). `post`
|
|
2639
|
+
// itself carries no `where` — see the findUnique test above (#974).
|
|
2232
2640
|
expect(innerIncludeSeen).toEqual({
|
|
2233
|
-
post: {
|
|
2641
|
+
post: { include: { author: true } },
|
|
2234
2642
|
})
|
|
2235
2643
|
})
|
|
2236
2644
|
})
|
|
@@ -2289,7 +2697,7 @@ describe('getContext', () => {
|
|
|
2289
2697
|
expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
|
|
2290
2698
|
})
|
|
2291
2699
|
|
|
2292
|
-
it('still
|
|
2700
|
+
it('still applies row-scoping at the deepest hop for the same include one hop shallower', async () => {
|
|
2293
2701
|
const chainLength = READ_INCLUDE_MAX_DEPTH + 1
|
|
2294
2702
|
const chainConfig: OpenSaasConfig = {
|
|
2295
2703
|
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
@@ -2300,7 +2708,20 @@ describe('getContext', () => {
|
|
|
2300
2708
|
for (let i = 0; i < chainLength; i++) {
|
|
2301
2709
|
chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
|
|
2302
2710
|
}
|
|
2303
|
-
|
|
2711
|
+
|
|
2712
|
+
// Every hop here is to-one (no `many: true`), so none of them can
|
|
2713
|
+
// carry a `where` on the Prisma include (#974) — the chain comes
|
|
2714
|
+
// back unscoped at the Prisma level, one nested object per hop, and
|
|
2715
|
+
// row-scoping is enforced afterward via a batched existence check
|
|
2716
|
+
// per hop instead.
|
|
2717
|
+
let deepestItem: Record<string, unknown> = { id: `id${chainLength - 1}`, name: 'leaf' }
|
|
2718
|
+
for (let i = chainLength - 2; i >= 0; i--) {
|
|
2719
|
+
deepestItem = { id: `id${i}`, name: 'x', next: deepestItem }
|
|
2720
|
+
}
|
|
2721
|
+
chainPrisma.c0.findMany.mockResolvedValue([deepestItem])
|
|
2722
|
+
for (let i = 1; i < chainLength; i++) {
|
|
2723
|
+
chainPrisma[`c${i}`].findMany.mockResolvedValue([{ id: `id${i}` }])
|
|
2724
|
+
}
|
|
2304
2725
|
|
|
2305
2726
|
const context = await getContext(chainConfig, chainPrisma, null)
|
|
2306
2727
|
|
|
@@ -2308,13 +2729,27 @@ describe('getContext', () => {
|
|
|
2308
2729
|
include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
|
|
2309
2730
|
})
|
|
2310
2731
|
|
|
2311
|
-
// Walk the built include down to the last list —
|
|
2312
|
-
//
|
|
2732
|
+
// Walk the built include down to the last list — no hop carries a
|
|
2733
|
+
// `where` (they are all to-one).
|
|
2313
2734
|
let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
|
|
2314
2735
|
for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
|
|
2736
|
+
expect(entry.where).toBeUndefined()
|
|
2315
2737
|
entry = entry.include.next
|
|
2316
2738
|
}
|
|
2317
|
-
expect(entry.where).
|
|
2739
|
+
expect(entry.where).toBeUndefined()
|
|
2740
|
+
|
|
2741
|
+
// The deepest hop's own existence check still carries that list's
|
|
2742
|
+
// access filter — row scoping reaches all the way down, just not
|
|
2743
|
+
// through the Prisma `include` itself.
|
|
2744
|
+
expect(chainPrisma[`c${READ_INCLUDE_MAX_DEPTH}`].findMany).toHaveBeenCalledWith({
|
|
2745
|
+
where: {
|
|
2746
|
+
AND: [
|
|
2747
|
+
{ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } },
|
|
2748
|
+
{ id: { in: [`id${READ_INCLUDE_MAX_DEPTH}`] } },
|
|
2749
|
+
],
|
|
2750
|
+
},
|
|
2751
|
+
select: { id: true },
|
|
2752
|
+
})
|
|
2318
2753
|
})
|
|
2319
2754
|
})
|
|
2320
2755
|
|